diff --git a/.deepsource.toml b/.deepsource.toml new file mode 100644 index 000000000000..c68a25b03206 --- /dev/null +++ b/.deepsource.toml @@ -0,0 +1,18 @@ +version = 1 + +test_patterns = ["**/*.spec.ts","**/*_test.py","**/*_tests.py","**/test_*.py"] + +exclude_patterns = ["classic/**"] + +[[analyzers]] +name = "javascript" + +[analyzers.meta] +plugins = ["react"] +environment = ["nodejs"] + +[[analyzers]] +name = "python" + +[analyzers.meta] +runtime_version = "3.x.x" diff --git a/.dockerignore b/.dockerignore index 0cf6b446c3b7..94bf1742f1c4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,40 +1,65 @@ # Ignore everything by default, selectively add things to context -classic/run +* -# AutoGPT +# Platform - Libs +!autogpt_platform/autogpt_libs/autogpt_libs/ +!autogpt_platform/autogpt_libs/pyproject.toml +!autogpt_platform/autogpt_libs/poetry.lock +!autogpt_platform/autogpt_libs/README.md + +# Platform - Backend +!autogpt_platform/backend/backend/ +!autogpt_platform/backend/test/e2e_test_data.py +!autogpt_platform/backend/migrations/ +!autogpt_platform/backend/schema.prisma +!autogpt_platform/backend/pyproject.toml +!autogpt_platform/backend/poetry.lock +!autogpt_platform/backend/README.md +!autogpt_platform/backend/.env + +# Platform - Market +!autogpt_platform/market/market/ +!autogpt_platform/market/scripts.py +!autogpt_platform/market/schema.prisma +!autogpt_platform/market/pyproject.toml +!autogpt_platform/market/poetry.lock +!autogpt_platform/market/README.md + +# Platform - Frontend +!autogpt_platform/frontend/src/ +!autogpt_platform/frontend/public/ +!autogpt_platform/frontend/scripts/ +!autogpt_platform/frontend/package.json +!autogpt_platform/frontend/pnpm-lock.yaml +!autogpt_platform/frontend/tsconfig.json +!autogpt_platform/frontend/README.md +## config +!autogpt_platform/frontend/*.config.* +!autogpt_platform/frontend/.env.* +!autogpt_platform/frontend/.env + +# Classic - AutoGPT !classic/original_autogpt/autogpt/ !classic/original_autogpt/pyproject.toml !classic/original_autogpt/poetry.lock !classic/original_autogpt/README.md !classic/original_autogpt/tests/ -# Benchmark +# Classic - Benchmark !classic/benchmark/agbenchmark/ !classic/benchmark/pyproject.toml !classic/benchmark/poetry.lock !classic/benchmark/README.md -# Forge +# Classic - Forge !classic/forge/ !classic/forge/pyproject.toml !classic/forge/poetry.lock !classic/forge/README.md -# Frontend +# Classic - Frontend !classic/frontend/build/web/ -# Platform -!autogpt_platform/ - # Explicitly re-ignore some folders .* **/__pycache__ - -autogpt_platform/frontend/.next/ -autogpt_platform/frontend/node_modules -autogpt_platform/frontend/.env.example -autogpt_platform/frontend/.env.local -autogpt_platform/backend/.env -autogpt_platform/backend/.venv/ - -autogpt_platform/market/.env diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9b348b557da0..2e37f67766ae 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -24,7 +24,8 @@ #### For configuration changes: -- [ ] `.env.example` is updated or already compatible with my changes + +- [ ] `.env.default` is updated or already compatible with my changes - [ ] `docker-compose.yml` is updated or already compatible with my changes - [ ] I have included a list of my configuration changes in the PR description (under **Changes**) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000000..a0834a691321 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,244 @@ +# GitHub Copilot Instructions for AutoGPT + +This file provides comprehensive onboarding information for GitHub Copilot coding agent to work efficiently with the AutoGPT repository. + +## Repository Overview + +**AutoGPT** is a powerful platform for creating, deploying, and managing continuous AI agents that automate complex workflows. This is a large monorepo (~150MB) containing multiple components: + +- **AutoGPT Platform** (`autogpt_platform/`) - Main focus: Modern AI agent platform (Polyform Shield License) +- **Classic AutoGPT** (`classic/`) - Legacy agent system (MIT License) +- **Documentation** (`docs/`) - MkDocs-based documentation site +- **Infrastructure** - Docker configurations, CI/CD, and development tools + +**Primary Languages & Frameworks:** +- **Backend**: Python 3.10-3.13, FastAPI, Prisma ORM, PostgreSQL, RabbitMQ +- **Frontend**: TypeScript, Next.js 15, React, Tailwind CSS, Radix UI +- **Development**: Docker, Poetry, pnpm, Playwright, Storybook + +## Build and Validation Instructions + +### Essential Setup Commands + +**Always run these commands in the correct directory and in this order:** + +1. **Initial Setup** (required once): + ```bash + # Clone and enter repository + git clone && cd AutoGPT + + # Start all services (database, redis, rabbitmq, clamav) + cd autogpt_platform && docker compose --profile local up deps --build --detach + ``` + +2. **Backend Setup** (always run before backend development): + ```bash + cd autogpt_platform/backend + poetry install # Install dependencies + poetry run prisma migrate dev # Run database migrations + poetry run prisma generate # Generate Prisma client + ``` + +3. **Frontend Setup** (always run before frontend development): + ```bash + cd autogpt_platform/frontend + pnpm install # Install dependencies + ``` + +### Runtime Requirements + +**Critical:** Always ensure Docker services are running before starting development: +```bash +cd autogpt_platform && docker compose --profile local up deps --build --detach +``` + +**Python Version:** Use Python 3.11 (required; managed by Poetry via pyproject.toml) +**Node.js Version:** Use Node.js 21+ with pnpm package manager + +### Development Commands + +**Backend Development:** +```bash +cd autogpt_platform/backend +poetry run serve # Start development server (port 8000) +poetry run test # Run all tests (requires ~5 minutes) +poetry run pytest path/to/test.py # Run specific test +poetry run format # Format code (Black + isort) - always run first +poetry run lint # Lint code (ruff) - run after format +``` + +**Frontend Development:** +```bash +cd autogpt_platform/frontend +pnpm dev # Start development server (port 3000) - use for active development +pnpm build # Build for production (only needed for E2E tests or deployment) +pnpm test # Run Playwright E2E tests (requires build first) +pnpm test-ui # Run tests with UI +pnpm format # Format and lint code +pnpm storybook # Start component development server +``` + +### Testing Strategy + +**Backend Tests:** +- **Block Tests**: `poetry run pytest backend/blocks/test/test_block.py -xvs` (validates all blocks) +- **Specific Block**: `poetry run pytest 'backend/blocks/test/test_block.py::test_available_blocks[BlockName]' -xvs` +- **Snapshot Tests**: Use `--snapshot-update` when output changes, always review with `git diff` + +**Frontend Tests:** +- **E2E Tests**: Always run `pnpm dev` before `pnpm test` (Playwright requires running instance) +- **Component Tests**: Use Storybook for isolated component development + +### Critical Validation Steps + +**Before committing changes:** +1. Run `poetry run format` (backend) and `pnpm format` (frontend) +2. Ensure all tests pass in modified areas +3. Verify Docker services are still running +4. Check that database migrations apply cleanly + +**Common Issues & Workarounds:** +- **Prisma issues**: Run `poetry run prisma generate` after schema changes +- **Permission errors**: Ensure Docker has proper permissions +- **Port conflicts**: Check the `docker-compose.yml` file for the current list of exposed ports. You can list all mapped ports with: +- **Test timeouts**: Backend tests can take 5+ minutes, use `-x` flag to stop on first failure + +## Project Layout & Architecture + +### Core Architecture + +**AutoGPT Platform** (`autogpt_platform/`): +- `backend/` - FastAPI server with async support + - `backend/backend/` - Core API logic + - `backend/blocks/` - Agent execution blocks + - `backend/data/` - Database models and schemas + - `schema.prisma` - Database schema definition +- `frontend/` - Next.js application + - `src/app/` - App Router pages and layouts + - `src/components/` - Reusable React components + - `src/lib/` - Utilities and configurations +- `autogpt_libs/` - Shared Python utilities +- `docker-compose.yml` - Development stack orchestration + +**Key Configuration Files:** +- `pyproject.toml` - Python dependencies and tooling +- `package.json` - Node.js dependencies and scripts +- `schema.prisma` - Database schema and migrations +- `next.config.mjs` - Next.js configuration +- `tailwind.config.ts` - Styling configuration + +### Security & Middleware + +**Cache Protection**: Backend includes middleware preventing sensitive data caching in browsers/proxies +**Authentication**: JWT-based with Supabase integration +**User ID Validation**: All data access requires user ID checks - verify this for any `data/*.py` changes + +### Development Workflow + +**GitHub Actions**: Multiple CI/CD workflows in `.github/workflows/` +- `platform-backend-ci.yml` - Backend testing and validation +- `platform-frontend-ci.yml` - Frontend testing and validation +- `platform-fullstack-ci.yml` - End-to-end integration tests + +**Pre-commit Hooks**: Run linting and formatting checks +**Conventional Commits**: Use format `type(scope): description` (e.g., `feat(backend): add API`) + +### Key Source Files + +**Backend Entry Points:** +- `backend/backend/server/server.py` - FastAPI application setup +- `backend/backend/data/` - Database models and user management +- `backend/blocks/` - Agent execution blocks and logic + +**Frontend Entry Points:** +- `frontend/src/app/layout.tsx` - Root application layout +- `frontend/src/app/page.tsx` - Home page +- `frontend/src/lib/supabase/` - Authentication and database client + +**Protected Routes**: Update `frontend/lib/supabase/middleware.ts` when adding protected routes + +### Agent Block System + +Agents are built using a visual block-based system where each block performs a single action. Blocks are defined in `backend/blocks/` and must include: +- Block definition with input/output schemas +- Execution logic with proper error handling +- Tests validating functionality + +### Database & ORM + +**Prisma ORM** with PostgreSQL backend including pgvector for embeddings: +- Schema in `schema.prisma` +- Migrations in `backend/migrations/` +- Always run `prisma migrate dev` and `prisma generate` after schema changes + +## Environment Configuration + +### Configuration Files Priority Order +1. **Backend**: `/backend/.env.default` → `/backend/.env` (user overrides) +2. **Frontend**: `/frontend/.env.default` → `/frontend/.env` (user overrides) +3. **Platform**: `/.env.default` (Supabase/shared) → `/.env` (user overrides) +4. Docker Compose `environment:` sections override file-based config +5. Shell environment variables have highest precedence + +### Docker Environment Setup +- All services use hardcoded defaults (no `${VARIABLE}` substitutions) +- The `env_file` directive loads variables INTO containers at runtime +- Backend/Frontend services use YAML anchors for consistent configuration +- Copy `.env.default` files to `.env` for local development customization + +## Advanced Development Patterns + +### Adding New Blocks +1. Create file in `/backend/backend/blocks/` +2. Inherit from `Block` base class with input/output schemas +3. Implement `run` method with proper error handling +4. Generate block UUID using `uuid.uuid4()` +5. Register in block registry +6. Write tests alongside block implementation +7. Consider how inputs/outputs connect with other blocks in graph editor + +### API Development +1. Update routes in `/backend/backend/server/routers/` +2. Add/update Pydantic models in same directory +3. Write tests alongside route files +4. For `data/*.py` changes, validate user ID checks +5. Run `poetry run test` to verify changes + +### Frontend Development +1. Components in `/frontend/src/components/` +2. Use existing UI components from `/frontend/src/components/ui/` +3. Add Storybook stories for component development +4. Test user-facing features with Playwright E2E tests +5. Update protected routes in middleware when needed + +### Security Guidelines +**Cache Protection Middleware** (`/backend/backend/server/middleware/security.py`): +- Default: Disables caching for ALL endpoints with `Cache-Control: no-store, no-cache, must-revalidate, private` +- Uses allow list approach for cacheable paths (static assets, health checks, public pages) +- Prevents sensitive data caching in browsers/proxies +- Add new cacheable endpoints to `CACHEABLE_PATHS` + +### CI/CD Alignment +The repository has comprehensive CI workflows that test: +- **Backend**: Python 3.11-3.13, services (Redis/RabbitMQ/ClamAV), Prisma migrations, Poetry lock validation +- **Frontend**: Node.js 21, pnpm, Playwright with Docker Compose stack, API schema validation +- **Integration**: Full-stack type checking and E2E testing + +Match these patterns when developing locally - the copilot setup environment mirrors these CI configurations. + +## Collaboration with Other AI Assistants + +This repository is actively developed with assistance from Claude (via CLAUDE.md files). When working on this codebase: +- Check for existing CLAUDE.md files that provide additional context +- Follow established patterns and conventions already in the codebase +- Maintain consistency with existing code style and architecture +- Consider that changes may be reviewed and extended by both human developers and AI assistants + +## Trust These Instructions + +These instructions are comprehensive and tested. Only perform additional searches if: +1. Information here is incomplete for your specific task +2. You encounter errors not covered by the workarounds +3. You need to understand implementation details not covered above + +For detailed platform development patterns, refer to `autogpt_platform/CLAUDE.md` and `AGENTS.md` in the repository root. \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 18191139f72e..b3ea5c483887 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -10,17 +10,19 @@ updates: commit-message: prefix: "chore(libs/deps)" prefix-development: "chore(libs/deps-dev)" + ignore: + - dependency-name: "poetry" groups: production-dependencies: dependency-type: "production" update-types: - - "minor" - - "patch" + - "minor" + - "patch" development-dependencies: dependency-type: "development" update-types: - - "minor" - - "patch" + - "minor" + - "patch" # backend (Poetry project) - package-ecosystem: "pip" @@ -32,17 +34,19 @@ updates: commit-message: prefix: "chore(backend/deps)" prefix-development: "chore(backend/deps-dev)" + ignore: + - dependency-name: "poetry" groups: production-dependencies: dependency-type: "production" update-types: - - "minor" - - "patch" + - "minor" + - "patch" development-dependencies: dependency-type: "development" update-types: - - "minor" - - "patch" + - "minor" + - "patch" # frontend (Next.js project) - package-ecosystem: "npm" @@ -58,13 +62,13 @@ updates: production-dependencies: dependency-type: "production" update-types: - - "minor" - - "patch" + - "minor" + - "patch" development-dependencies: dependency-type: "development" update-types: - - "minor" - - "patch" + - "minor" + - "patch" # infra (Terraform) - package-ecosystem: "terraform" @@ -81,36 +85,13 @@ updates: production-dependencies: dependency-type: "production" update-types: - - "minor" - - "patch" + - "minor" + - "patch" development-dependencies: dependency-type: "development" update-types: - - "minor" - - "patch" - - # market (Poetry project) - - package-ecosystem: "pip" - directory: "autogpt_platform/market" - schedule: - interval: "weekly" - open-pull-requests-limit: 10 - target-branch: "dev" - commit-message: - prefix: "chore(market/deps)" - prefix-development: "chore(market/deps-dev)" - groups: - production-dependencies: - dependency-type: "production" - update-types: - - "minor" - - "patch" - development-dependencies: - dependency-type: "development" - update-types: - - "minor" - - "patch" - + - "minor" + - "patch" # GitHub Actions - package-ecosystem: "github-actions" @@ -123,14 +104,13 @@ updates: production-dependencies: dependency-type: "production" update-types: - - "minor" - - "patch" + - "minor" + - "patch" development-dependencies: dependency-type: "development" update-types: - - "minor" - - "patch" - + - "minor" + - "patch" # Docker - package-ecosystem: "docker" @@ -143,40 +123,16 @@ updates: production-dependencies: dependency-type: "production" update-types: - - "minor" - - "patch" + - "minor" + - "patch" development-dependencies: dependency-type: "development" update-types: - - "minor" - - "patch" - - - # Submodules - - package-ecosystem: "gitsubmodule" - directory: "autogpt_platform/supabase" - schedule: - interval: "weekly" - open-pull-requests-limit: 1 - target-branch: "dev" - commit-message: - prefix: "chore(platform/deps)" - prefix-development: "chore(platform/deps-dev)" - groups: - production-dependencies: - dependency-type: "production" - update-types: - - "minor" - - "patch" - development-dependencies: - dependency-type: "development" - update-types: - - "minor" - - "patch" - + - "minor" + - "patch" # Docs - - package-ecosystem: 'pip' + - package-ecosystem: "pip" directory: "docs/" schedule: interval: "weekly" @@ -188,10 +144,10 @@ updates: production-dependencies: dependency-type: "production" update-types: - - "minor" - - "patch" + - "minor" + - "patch" development-dependencies: dependency-type: "development" update-types: - - "minor" - - "patch" + - "minor" + - "patch" diff --git a/.github/labeler.yml b/.github/labeler.yml index 8d2346983805..6cd4e4f67e90 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -24,8 +24,9 @@ platform/frontend: platform/backend: - changed-files: - - any-glob-to-any-file: autogpt_platform/backend/** - - all-globs-to-all-files: '!autogpt_platform/backend/backend/blocks/**' + - all-globs-to-any-file: + - autogpt_platform/backend/** + - '!autogpt_platform/backend/backend/blocks/**' platform/blocks: - changed-files: diff --git a/.github/workflows/classic-autogpt-ci.yml b/.github/workflows/classic-autogpt-ci.yml index e549da8ae05d..1a1063942219 100644 --- a/.github/workflows/classic-autogpt-ci.yml +++ b/.github/workflows/classic-autogpt-ci.yml @@ -115,6 +115,7 @@ jobs: poetry run pytest -vv \ --cov=autogpt --cov-branch --cov-report term-missing --cov-report xml \ --numprocesses=logical --durations=10 \ + --junitxml=junit.xml -o junit_family=legacy \ tests/unit tests/integration env: CI: true @@ -124,8 +125,14 @@ jobs: AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin + - name: Upload test results to Codecov + if: ${{ !cancelled() }} # Run even if tests fail + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} flags: autogpt-agent,${{ runner.os }} diff --git a/.github/workflows/classic-benchmark-ci.yml b/.github/workflows/classic-benchmark-ci.yml index a239e7b29477..0807620df993 100644 --- a/.github/workflows/classic-benchmark-ci.yml +++ b/.github/workflows/classic-benchmark-ci.yml @@ -87,13 +87,20 @@ jobs: poetry run pytest -vv \ --cov=agbenchmark --cov-branch --cov-report term-missing --cov-report xml \ --durations=10 \ + --junitxml=junit.xml -o junit_family=legacy \ tests env: CI: true OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + - name: Upload test results to Codecov + if: ${{ !cancelled() }} # Run even if tests fail + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} flags: agbenchmark,${{ runner.os }} diff --git a/.github/workflows/classic-forge-ci.yml b/.github/workflows/classic-forge-ci.yml index 4642f57521f7..594c0fd07668 100644 --- a/.github/workflows/classic-forge-ci.yml +++ b/.github/workflows/classic-forge-ci.yml @@ -139,6 +139,7 @@ jobs: poetry run pytest -vv \ --cov=forge --cov-branch --cov-report term-missing --cov-report xml \ --durations=10 \ + --junitxml=junit.xml -o junit_family=legacy \ forge env: CI: true @@ -148,8 +149,14 @@ jobs: AWS_ACCESS_KEY_ID: minioadmin AWS_SECRET_ACCESS_KEY: minioadmin + - name: Upload test results to Codecov + if: ${{ !cancelled() }} # Run even if tests fail + uses: codecov/test-results-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} flags: forge,${{ runner.os }} diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 000000000000..399044d8f723 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,47 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + ( + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + ) && ( + github.event.comment.author_association == 'OWNER' || + github.event.comment.author_association == 'MEMBER' || + github.event.comment.author_association == 'COLLABORATOR' || + github.event.review.author_association == 'OWNER' || + github.event.review.author_association == 'MEMBER' || + github.event.review.author_association == 'COLLABORATOR' || + github.event.issue.author_association == 'OWNER' || + github.event.issue.author_association == 'MEMBER' || + github.event.issue.author_association == 'COLLABORATOR' + ) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@beta + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 000000000000..7af1ec436523 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,302 @@ +name: "Copilot Setup Steps" + +# Automatically run the setup steps when they are changed to allow for easy validation, and +# allow manual testing through the repository's "Actions" tab +on: + workflow_dispatch: + push: + paths: + - .github/workflows/copilot-setup-steps.yml + pull_request: + paths: + - .github/workflows/copilot-setup-steps.yml + +jobs: + # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. + copilot-setup-steps: + runs-on: ubuntu-latest + timeout-minutes: 45 + + # Set the permissions to the lowest permissions possible needed for your steps. + # Copilot will be given its own token for its operations. + permissions: + # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. + contents: read + + # You can define any steps you want, and they will run before the agent starts. + # If you do not check out your code, Copilot will do this for you. + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + submodules: true + + # Backend Python/Poetry setup (mirrors platform-backend-ci.yml) + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" # Use standard version matching CI + + - name: Set up Python dependency cache + uses: actions/cache@v4 + with: + path: ~/.cache/pypoetry + key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }} + + - name: Install Poetry + run: | + # Extract Poetry version from backend/poetry.lock (matches CI) + cd autogpt_platform/backend + HEAD_POETRY_VERSION=$(python3 ../../.github/workflows/scripts/get_package_version_from_lockfile.py poetry) + echo "Found Poetry version ${HEAD_POETRY_VERSION} in backend/poetry.lock" + + # Install Poetry + curl -sSL https://install.python-poetry.org | POETRY_VERSION=$HEAD_POETRY_VERSION python3 - + + # Add Poetry to PATH + echo "$HOME/.local/bin" >> $GITHUB_PATH + + - name: Check poetry.lock + working-directory: autogpt_platform/backend + run: | + poetry lock + if ! git diff --quiet --ignore-matching-lines="^# " poetry.lock; then + echo "Warning: poetry.lock not up to date, but continuing for setup" + git checkout poetry.lock # Reset for clean setup + fi + + - name: Install Python dependencies + working-directory: autogpt_platform/backend + run: poetry install + + - name: Generate Prisma Client + working-directory: autogpt_platform/backend + run: poetry run prisma generate + + # Frontend Node.js/pnpm setup (mirrors platform-frontend-ci.yml) + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "21" + + - name: Enable corepack + run: corepack enable + + - name: Set pnpm store directory + run: | + pnpm config set store-dir ~/.pnpm-store + echo "PNPM_HOME=$HOME/.pnpm-store" >> $GITHUB_ENV + + - name: Cache frontend dependencies + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }} + restore-keys: | + ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }} + ${{ runner.os }}-pnpm- + + - name: Install JavaScript dependencies + working-directory: autogpt_platform/frontend + run: pnpm install --frozen-lockfile + + # Install Playwright browsers for frontend testing + # NOTE: Disabled to save ~1 minute of setup time. Re-enable if Copilot needs browser automation (e.g., for MCP) + # - name: Install Playwright browsers + # working-directory: autogpt_platform/frontend + # run: pnpm playwright install --with-deps chromium + + # Docker setup for development environment + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Copy default environment files + working-directory: autogpt_platform + run: | + # Copy default environment files for development + cp .env.default .env + cp backend/.env.default backend/.env + cp frontend/.env.default frontend/.env + + # Phase 1: Cache and load Docker images for faster setup + - name: Set up Docker image cache + id: docker-cache + uses: actions/cache@v4 + with: + path: ~/docker-cache + # Use a versioned key for cache invalidation when image list changes + key: docker-images-v2-${{ runner.os }}-${{ hashFiles('.github/workflows/copilot-setup-steps.yml') }} + restore-keys: | + docker-images-v2-${{ runner.os }}- + docker-images-v1-${{ runner.os }}- + + - name: Load or pull Docker images + working-directory: autogpt_platform + run: | + mkdir -p ~/docker-cache + + # Define image list for easy maintenance + IMAGES=( + "redis:latest" + "rabbitmq:management" + "clamav/clamav-debian:latest" + "busybox:latest" + "kong:2.8.1" + "supabase/gotrue:v2.170.0" + "supabase/postgres:15.8.1.049" + "supabase/postgres-meta:v0.86.1" + "supabase/studio:20250224-d10db0f" + ) + + # Check if any cached tar files exist (more reliable than cache-hit) + if ls ~/docker-cache/*.tar 1> /dev/null 2>&1; then + echo "Docker cache found, loading images in parallel..." + for image in "${IMAGES[@]}"; do + # Convert image name to filename (replace : and / with -) + filename=$(echo "$image" | tr ':/' '--') + if [ -f ~/docker-cache/${filename}.tar ]; then + echo "Loading $image..." + docker load -i ~/docker-cache/${filename}.tar || echo "Warning: Failed to load $image from cache" & + fi + done + wait + echo "All cached images loaded" + else + echo "No Docker cache found, pulling images in parallel..." + # Pull all images in parallel + for image in "${IMAGES[@]}"; do + docker pull "$image" & + done + wait + + # Only save cache on main branches (not PRs) to avoid cache pollution + if [[ "${{ github.ref }}" == "refs/heads/master" ]] || [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then + echo "Saving Docker images to cache in parallel..." + for image in "${IMAGES[@]}"; do + # Convert image name to filename (replace : and / with -) + filename=$(echo "$image" | tr ':/' '--') + echo "Saving $image..." + docker save -o ~/docker-cache/${filename}.tar "$image" || echo "Warning: Failed to save $image" & + done + wait + echo "Docker image cache saved" + else + echo "Skipping cache save for PR/feature branch" + fi + fi + + echo "Docker images ready for use" + + # Phase 2: Build migrate service with GitHub Actions cache + - name: Build migrate Docker image with cache + working-directory: autogpt_platform + run: | + # Build the migrate image with buildx for GHA caching + docker buildx build \ + --cache-from type=gha \ + --cache-to type=gha,mode=max \ + --target migrate \ + --tag autogpt_platform-migrate:latest \ + --load \ + -f backend/Dockerfile \ + .. + + # Start services using pre-built images + - name: Start Docker services for development + working-directory: autogpt_platform + run: | + # Start essential services (migrate image already built with correct tag) + docker compose --profile local up deps --no-build --detach + echo "Waiting for services to be ready..." + + # Wait for database to be ready + echo "Checking database readiness..." + timeout 30 sh -c 'until docker compose exec -T db pg_isready -U postgres 2>/dev/null; do + echo " Waiting for database..." + sleep 2 + done' && echo "✅ Database is ready" || echo "⚠️ Database ready check timeout after 30s, continuing..." + + # Check migrate service status + echo "Checking migration status..." + docker compose ps migrate || echo " Migrate service not visible in ps output" + + # Wait for migrate service to complete + echo "Waiting for migrations to complete..." + timeout 30 bash -c ' + ATTEMPTS=0 + while [ $ATTEMPTS -lt 15 ]; do + ATTEMPTS=$((ATTEMPTS + 1)) + + # Check using docker directly (more reliable than docker compose ps) + CONTAINER_STATUS=$(docker ps -a --filter "label=com.docker.compose.service=migrate" --format "{{.Status}}" | head -1) + + if [ -z "$CONTAINER_STATUS" ]; then + echo " Attempt $ATTEMPTS: Migrate container not found yet..." + elif echo "$CONTAINER_STATUS" | grep -q "Exited (0)"; then + echo "✅ Migrations completed successfully" + docker compose logs migrate --tail=5 2>/dev/null || true + exit 0 + elif echo "$CONTAINER_STATUS" | grep -q "Exited ([1-9]"; then + EXIT_CODE=$(echo "$CONTAINER_STATUS" | grep -oE "Exited \([0-9]+\)" | grep -oE "[0-9]+") + echo "❌ Migrations failed with exit code: $EXIT_CODE" + echo "Migration logs:" + docker compose logs migrate --tail=20 2>/dev/null || true + exit 1 + elif echo "$CONTAINER_STATUS" | grep -q "Up"; then + echo " Attempt $ATTEMPTS: Migrate container is running... ($CONTAINER_STATUS)" + else + echo " Attempt $ATTEMPTS: Migrate container status: $CONTAINER_STATUS" + fi + + sleep 2 + done + + echo "⚠️ Timeout: Could not determine migration status after 30 seconds" + echo "Final container check:" + docker ps -a --filter "label=com.docker.compose.service=migrate" || true + echo "Migration logs (if available):" + docker compose logs migrate --tail=10 2>/dev/null || echo " No logs available" + ' || echo "⚠️ Migration check completed with warnings, continuing..." + + # Brief wait for other services to stabilize + echo "Waiting 5 seconds for other services to stabilize..." + sleep 5 + + # Verify installations and provide environment info + - name: Verify setup and show environment info + run: | + echo "=== Python Setup ===" + python --version + poetry --version + + echo "=== Node.js Setup ===" + node --version + pnpm --version + + echo "=== Additional Tools ===" + docker --version + docker compose version + gh --version || true + + echo "=== Services Status ===" + cd autogpt_platform + docker compose ps || true + + echo "=== Backend Dependencies ===" + cd backend + poetry show | head -10 || true + + echo "=== Frontend Dependencies ===" + cd ../frontend + pnpm list --depth=0 | head -10 || true + + echo "=== Environment Files ===" + ls -la ../.env* || true + ls -la .env* || true + ls -la ../backend/.env* || true + + echo "✅ AutoGPT Platform development environment setup complete!" + echo "🚀 Ready for development with Docker services running" + echo "📝 Backend server: poetry run serve (port 8000)" + echo "🌐 Frontend server: pnpm dev (port 3000)" \ No newline at end of file diff --git a/.github/workflows/platform-autgpt-deploy-prod.yml b/.github/workflows/platform-autgpt-deploy-prod.yml index 1c0fdc387bed..d0a8210253a2 100644 --- a/.github/workflows/platform-autgpt-deploy-prod.yml +++ b/.github/workflows/platform-autgpt-deploy-prod.yml @@ -34,13 +34,8 @@ jobs: python -m prisma migrate deploy env: DATABASE_URL: ${{ secrets.BACKEND_DATABASE_URL }} + DIRECT_URL: ${{ secrets.BACKEND_DATABASE_URL }} - - name: Run Market Migrations - working-directory: ./autogpt_platform/market - run: | - python -m prisma migrate deploy - env: - DATABASE_URL: ${{ secrets.MARKET_DATABASE_URL }} trigger: needs: migrate diff --git a/.github/workflows/platform-autogpt-deploy-dev.yaml b/.github/workflows/platform-autogpt-deploy-dev.yaml index 632a0e7aa6d4..44da8641ec17 100644 --- a/.github/workflows/platform-autogpt-deploy-dev.yaml +++ b/.github/workflows/platform-autogpt-deploy-dev.yaml @@ -36,13 +36,7 @@ jobs: python -m prisma migrate deploy env: DATABASE_URL: ${{ secrets.BACKEND_DATABASE_URL }} - - - name: Run Market Migrations - working-directory: ./autogpt_platform/market - run: | - python -m prisma migrate deploy - env: - DATABASE_URL: ${{ secrets.MARKET_DATABASE_URL }} + DIRECT_URL: ${{ secrets.BACKEND_DATABASE_URL }} trigger: needs: migrate diff --git a/.github/workflows/platform-backend-ci.yml b/.github/workflows/platform-backend-ci.yml index 9ea0f4f27991..98a99de43f4a 100644 --- a/.github/workflows/platform-backend-ci.yml +++ b/.github/workflows/platform-backend-ci.yml @@ -32,7 +32,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10"] + python-version: ["3.11", "3.12", "3.13"] runs-on: ubuntu-latest services: @@ -42,6 +42,31 @@ jobs: REDIS_PASSWORD: testpassword ports: - 6379:6379 + rabbitmq: + image: rabbitmq:3.12-management + ports: + - 5672:5672 + - 15672:15672 + env: + RABBITMQ_DEFAULT_USER: ${{ env.RABBITMQ_DEFAULT_USER }} + RABBITMQ_DEFAULT_PASS: ${{ env.RABBITMQ_DEFAULT_PASS }} + clamav: + image: clamav/clamav-debian:latest + ports: + - 3310:3310 + env: + CLAMAV_NO_FRESHCLAMD: false + CLAMD_CONF_StreamMaxLength: 50M + CLAMD_CONF_MaxFileSize: 100M + CLAMD_CONF_MaxScanSize: 100M + CLAMD_CONF_MaxThreads: 4 + CLAMD_CONF_ReadTimeout: 300 + options: >- + --health-cmd "clamdscan --version || exit 1" + --health-interval 30s + --health-timeout 10s + --health-retries 5 + --health-start-period 180s steps: - name: Checkout repository @@ -58,7 +83,7 @@ jobs: - name: Setup Supabase uses: supabase/setup-cli@v1 with: - version: latest + version: 1.178.1 - id: get_date name: Get date @@ -72,18 +97,35 @@ jobs: - name: Install Poetry (Unix) run: | - curl -sSL https://install.python-poetry.org | python3 - + # Extract Poetry version from backend/poetry.lock + HEAD_POETRY_VERSION=$(python ../../.github/workflows/scripts/get_package_version_from_lockfile.py poetry) + echo "Found Poetry version ${HEAD_POETRY_VERSION} in backend/poetry.lock" + + if [ -n "$BASE_REF" ]; then + BASE_BRANCH=${BASE_REF/refs\/heads\//} + BASE_POETRY_VERSION=$((git show "origin/$BASE_BRANCH":./poetry.lock; true) | python ../../.github/workflows/scripts/get_package_version_from_lockfile.py poetry -) + echo "Found Poetry version ${BASE_POETRY_VERSION} in backend/poetry.lock on ${BASE_REF}" + POETRY_VERSION=$(printf '%s\n' "$HEAD_POETRY_VERSION" "$BASE_POETRY_VERSION" | sort -V | tail -n1) + else + POETRY_VERSION=$HEAD_POETRY_VERSION + fi + echo "Using Poetry version ${POETRY_VERSION}" + + # Install Poetry + curl -sSL https://install.python-poetry.org | POETRY_VERSION=$POETRY_VERSION python3 - if [ "${{ runner.os }}" = "macOS" ]; then PATH="$HOME/.local/bin:$PATH" echo "$HOME/.local/bin" >> $GITHUB_PATH fi + env: + BASE_REF: ${{ github.base_ref || github.event.merge_group.base_ref }} - name: Check poetry.lock run: | - poetry lock --no-update + poetry lock - if ! git diff --quiet poetry.lock; then + if ! git diff --quiet --ignore-matching-lines="^# " poetry.lock; then echo "Error: poetry.lock not up to date." echo git diff poetry.lock @@ -106,10 +148,40 @@ jobs: # outputs: # DB_URL, API_URL, GRAPHQL_URL, ANON_KEY, SERVICE_ROLE_KEY, JWT_SECRET + - name: Wait for ClamAV to be ready + run: | + echo "Waiting for ClamAV daemon to start..." + max_attempts=60 + attempt=0 + + until nc -z localhost 3310 || [ $attempt -eq $max_attempts ]; do + echo "ClamAV is unavailable - sleeping (attempt $((attempt+1))/$max_attempts)" + sleep 5 + attempt=$((attempt+1)) + done + + if [ $attempt -eq $max_attempts ]; then + echo "ClamAV failed to start after $((max_attempts*5)) seconds" + echo "Checking ClamAV service logs..." + docker logs $(docker ps -q --filter "ancestor=clamav/clamav-debian:latest") 2>&1 | tail -50 || echo "No ClamAV container found" + exit 1 + fi + + echo "ClamAV is ready!" + + # Verify ClamAV is responsive + echo "Testing ClamAV connection..." + timeout 10 bash -c 'echo "PING" | nc localhost 3310' || { + echo "ClamAV is not responding to PING" + docker logs $(docker ps -q --filter "ancestor=clamav/clamav-debian:latest") 2>&1 | tail -50 || echo "No ClamAV container found" + exit 1 + } + - name: Run Database Migrations run: poetry run prisma migrate dev --name updates env: DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }} + DIRECT_URL: ${{ steps.supabase.outputs.DB_URL }} - id: lint name: Run Linter @@ -118,20 +190,22 @@ jobs: - name: Run pytest with coverage run: | if [[ "${{ runner.debug }}" == "1" ]]; then - poetry run pytest -s -vv -o log_cli=true -o log_cli_level=DEBUG test + poetry run pytest -s -vv -o log_cli=true -o log_cli_level=DEBUG else - poetry run pytest -s -vv test + poetry run pytest -s -vv fi if: success() || (failure() && steps.lint.outcome == 'failure') env: LOG_LEVEL: ${{ runner.debug && 'DEBUG' || 'INFO' }} DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }} + DIRECT_URL: ${{ steps.supabase.outputs.DB_URL }} SUPABASE_URL: ${{ steps.supabase.outputs.API_URL }} SUPABASE_SERVICE_ROLE_KEY: ${{ steps.supabase.outputs.SERVICE_ROLE_KEY }} SUPABASE_JWT_SECRET: ${{ steps.supabase.outputs.JWT_SECRET }} - REDIS_HOST: 'localhost' - REDIS_PORT: '6379' - REDIS_PASSWORD: 'testpassword' + REDIS_HOST: "localhost" + REDIS_PORT: "6379" + REDIS_PASSWORD: "testpassword" + ENCRYPTION_KEY: "dvziYgz0KSK8FENhju0ZYi8-fRTfAdlz6YLhdB_jhNw=" # DO NOT USE IN PRODUCTION!! env: CI: true @@ -139,6 +213,13 @@ jobs: RUN_ENV: local PORT: 8080 OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + # We know these are here, don't report this as a security vulnerability + # This is used as the default credential for the entire system's RabbitMQ instance + # If you want to replace this, you can do so by making our entire system generate + # new credentials for each local user and update the environment variables in + # the backend service, docker composes, and examples + RABBITMQ_DEFAULT_USER: "rabbitmq_user_default" + RABBITMQ_DEFAULT_PASS: "k0VMxyIJF9S35f3x2uaw5IWAl6Y536O7" # - name: Upload coverage reports to Codecov # uses: codecov/codecov-action@v4 diff --git a/.github/workflows/platform-dev-deploy-event-dispatcher.yml b/.github/workflows/platform-dev-deploy-event-dispatcher.yml new file mode 100644 index 000000000000..d7915f64c695 --- /dev/null +++ b/.github/workflows/platform-dev-deploy-event-dispatcher.yml @@ -0,0 +1,198 @@ +name: AutoGPT Platform - Dev Deploy PR Event Dispatcher + +on: + pull_request: + types: [closed] + issue_comment: + types: [created] + +permissions: + issues: write + pull-requests: write + +jobs: + dispatch: + runs-on: ubuntu-latest + steps: + - name: Check comment permissions and deployment status + id: check_status + if: github.event_name == 'issue_comment' && github.event.issue.pull_request + uses: actions/github-script@v7 + with: + script: | + const commentBody = context.payload.comment.body.trim(); + const commentUser = context.payload.comment.user.login; + const prAuthor = context.payload.issue.user.login; + const authorAssociation = context.payload.comment.author_association; + + // Check permissions + const hasPermission = ( + authorAssociation === 'OWNER' || + authorAssociation === 'MEMBER' || + authorAssociation === 'COLLABORATOR' + ); + + core.setOutput('comment_body', commentBody); + core.setOutput('has_permission', hasPermission); + + if (!hasPermission && (commentBody === '!deploy' || commentBody === '!undeploy')) { + core.setOutput('permission_denied', 'true'); + return; + } + + if (commentBody !== '!deploy' && commentBody !== '!undeploy') { + return; + } + + // Process deploy command + if (commentBody === '!deploy') { + core.setOutput('should_deploy', 'true'); + } + // Process undeploy command + else if (commentBody === '!undeploy') { + core.setOutput('should_undeploy', 'true'); + } + + - name: Post permission denied comment + if: steps.check_status.outputs.permission_denied == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `❌ **Permission denied**: Only the repository owners, members, or collaborators can use deployment commands.` + }); + + - name: Get PR details for deployment + id: pr_details + if: steps.check_status.outputs.should_deploy == 'true' || steps.check_status.outputs.should_undeploy == 'true' + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('pr_number', pr.data.number); + core.setOutput('pr_title', pr.data.title); + core.setOutput('pr_state', pr.data.state); + + - name: Dispatch Deploy Event + if: steps.check_status.outputs.should_deploy == 'true' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.DISPATCH_TOKEN }} + repository: Significant-Gravitas/AutoGPT_cloud_infrastructure + event-type: pr-event + client-payload: | + { + "action": "deploy", + "pr_number": "${{ steps.pr_details.outputs.pr_number }}", + "pr_title": "${{ steps.pr_details.outputs.pr_title }}", + "pr_state": "${{ steps.pr_details.outputs.pr_state }}", + "repo": "${{ github.repository }}" + } + + - name: Post deploy success comment + if: steps.check_status.outputs.should_deploy == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `🚀 **Deploying PR #${{ steps.pr_details.outputs.pr_number }}** to development environment...` + }); + + - name: Dispatch Undeploy Event (from comment) + if: steps.check_status.outputs.should_undeploy == 'true' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.DISPATCH_TOKEN }} + repository: Significant-Gravitas/AutoGPT_cloud_infrastructure + event-type: pr-event + client-payload: | + { + "action": "undeploy", + "pr_number": "${{ steps.pr_details.outputs.pr_number }}", + "pr_title": "${{ steps.pr_details.outputs.pr_title }}", + "pr_state": "${{ steps.pr_details.outputs.pr_state }}", + "repo": "${{ github.repository }}" + } + + - name: Post undeploy success comment + if: steps.check_status.outputs.should_undeploy == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `🗑️ **Undeploying PR #${{ steps.pr_details.outputs.pr_number }}** from development environment...` + }); + + - name: Check deployment status on PR close + id: check_pr_close + if: github.event_name == 'pull_request' && github.event.action == 'closed' + uses: actions/github-script@v7 + with: + script: | + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number + }); + + let lastDeployIndex = -1; + let lastUndeployIndex = -1; + + comments.data.forEach((comment, index) => { + if (comment.body.trim() === '!deploy') { + lastDeployIndex = index; + } else if (comment.body.trim() === '!undeploy') { + lastUndeployIndex = index; + } + }); + + // Should undeploy if there's a !deploy without a subsequent !undeploy + const shouldUndeploy = lastDeployIndex !== -1 && lastDeployIndex > lastUndeployIndex; + core.setOutput('should_undeploy', shouldUndeploy); + + - name: Dispatch Undeploy Event (PR closed with active deployment) + if: >- + github.event_name == 'pull_request' && + github.event.action == 'closed' && + steps.check_pr_close.outputs.should_undeploy == 'true' + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.DISPATCH_TOKEN }} + repository: Significant-Gravitas/AutoGPT_cloud_infrastructure + event-type: pr-event + client-payload: | + { + "action": "undeploy", + "pr_number": "${{ github.event.pull_request.number }}", + "pr_title": "${{ github.event.pull_request.title }}", + "pr_state": "${{ github.event.pull_request.state }}", + "repo": "${{ github.repository }}" + } + + - name: Post PR close undeploy comment + if: >- + github.event_name == 'pull_request' && + github.event.action == 'closed' && + steps.check_pr_close.outputs.should_undeploy == 'true' + uses: actions/github-script@v7 + with: + script: | + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: `🧹 **Auto-undeploying**: PR closed with active deployment. Cleaning up development environment for PR #${{ github.event.pull_request.number }}.` + }); \ No newline at end of file diff --git a/.github/workflows/platform-frontend-ci.yml b/.github/workflows/platform-frontend-ci.yml index 29394392c4f2..e9d720a4fb99 100644 --- a/.github/workflows/platform-frontend-ci.yml +++ b/.github/workflows/platform-frontend-ci.yml @@ -18,31 +18,116 @@ defaults: working-directory: autogpt_platform/frontend jobs: + setup: + runs-on: ubuntu-latest + outputs: + cache-key: ${{ steps.cache-key.outputs.key }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "21" + + - name: Enable corepack + run: corepack enable + + - name: Generate cache key + id: cache-key + run: echo "key=${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ steps.cache-key.outputs.key }} + restore-keys: | + ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }} + ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + lint: runs-on: ubuntu-latest + needs: setup steps: - - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - name: Set up Node.js uses: actions/setup-node@v4 with: node-version: "21" + - name: Enable corepack + run: corepack enable + + - name: Restore dependencies cache + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ needs.setup.outputs.cache-key }} + restore-keys: | + ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }} + ${{ runner.os }}-pnpm- + - name: Install dependencies - run: | - yarn install --frozen-lockfile + run: pnpm install --frozen-lockfile - name: Run lint - run: | - yarn lint + run: pnpm lint - test: + chromatic: runs-on: ubuntu-latest + needs: setup + # Only run on dev branch pushes or PRs targeting dev + if: github.ref == 'refs/heads/dev' || github.base_ref == 'dev' + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "21" + + - name: Enable corepack + run: corepack enable + + - name: Restore dependencies cache + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ needs.setup.outputs.cache-key }} + restore-keys: | + ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }} + ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run Chromatic + uses: chromaui/action@latest + with: + projectToken: chpt_9e7c1a76478c9c8 + onlyChanged: true + workingDir: autogpt_platform/frontend + token: ${{ secrets.GITHUB_TOKEN }} + exitOnceUploaded: true + + test: + runs-on: big-boi + needs: setup strategy: fail-fast: false - matrix: - browser: [chromium, webkit] steps: - name: Checkout repository @@ -55,42 +140,96 @@ jobs: with: node-version: "21" - - name: Free Disk Space (Ubuntu) - uses: jlumbroso/free-disk-space@main - with: - large-packages: false # slow - docker-images: false # limited benefit + - name: Enable corepack + run: corepack enable - name: Copy default supabase .env run: | - cp ../supabase/docker/.env.example ../.env + cp ../.env.default ../.env - - name: Copy backend .env - run: | - cp ../backend/.env.example ../backend/.env + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Cache Docker layers + uses: actions/cache@v4 + with: + path: /tmp/.buildx-cache + key: ${{ runner.os }}-buildx-frontend-test-${{ hashFiles('autogpt_platform/docker-compose.yml', 'autogpt_platform/backend/Dockerfile', 'autogpt_platform/backend/pyproject.toml', 'autogpt_platform/backend/poetry.lock') }} + restore-keys: | + ${{ runner.os }}-buildx-frontend-test- - name: Run docker compose run: | docker compose -f ../docker-compose.yml up -d + env: + DOCKER_BUILDKIT: 1 + BUILDX_CACHE_FROM: type=local,src=/tmp/.buildx-cache + BUILDX_CACHE_TO: type=local,dest=/tmp/.buildx-cache-new,mode=max - - name: Install dependencies + - name: Move cache run: | - yarn install --frozen-lockfile + rm -rf /tmp/.buildx-cache + if [ -d "/tmp/.buildx-cache-new" ]; then + mv /tmp/.buildx-cache-new /tmp/.buildx-cache + fi - - name: Setup Builder .env + - name: Wait for services to be ready run: | - cp .env.example .env + echo "Waiting for rest_server to be ready..." + timeout 60 sh -c 'until curl -f http://localhost:8006/health 2>/dev/null; do sleep 2; done' || echo "Rest server health check timeout, continuing..." + echo "Waiting for database to be ready..." + timeout 60 sh -c 'until docker compose -f ../docker-compose.yml exec -T db pg_isready -U postgres 2>/dev/null; do sleep 2; done' || echo "Database ready check timeout, continuing..." - - name: Install Browser '${{ matrix.browser }}' - run: yarn playwright install --with-deps ${{ matrix.browser }} - - - name: Run tests + - name: Create E2E test data run: | - yarn test --project=${{ matrix.browser }} + echo "Creating E2E test data..." + # First try to run the script from inside the container + if docker compose -f ../docker-compose.yml exec -T rest_server test -f /app/autogpt_platform/backend/test/e2e_test_data.py; then + echo "✅ Found e2e_test_data.py in container, running it..." + docker compose -f ../docker-compose.yml exec -T rest_server sh -c "cd /app/autogpt_platform && python backend/test/e2e_test_data.py" || { + echo "❌ E2E test data creation failed!" + docker compose -f ../docker-compose.yml logs --tail=50 rest_server + exit 1 + } + else + echo "⚠️ e2e_test_data.py not found in container, copying and running..." + # Copy the script into the container and run it + docker cp ../backend/test/e2e_test_data.py $(docker compose -f ../docker-compose.yml ps -q rest_server):/tmp/e2e_test_data.py || { + echo "❌ Failed to copy script to container" + exit 1 + } + docker compose -f ../docker-compose.yml exec -T rest_server sh -c "cd /app/autogpt_platform && python /tmp/e2e_test_data.py" || { + echo "❌ E2E test data creation failed!" + docker compose -f ../docker-compose.yml logs --tail=50 rest_server + exit 1 + } + fi + + - name: Restore dependencies cache + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ needs.setup.outputs.cache-key }} + restore-keys: | + ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }} + ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile - - uses: actions/upload-artifact@v4 - if: ${{ !cancelled() }} + - name: Install Browser 'chromium' + run: pnpm playwright install --with-deps chromium + + - name: Run Playwright tests + run: pnpm test:no-build + + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 with: - name: playwright-report-${{ matrix.browser }} - path: playwright-report/ - retention-days: 30 + name: playwright-report + path: playwright-report + + - name: Print Final Docker Compose logs + if: always() + run: docker compose -f ../docker-compose.yml logs diff --git a/.github/workflows/platform-fullstack-ci.yml b/.github/workflows/platform-fullstack-ci.yml new file mode 100644 index 000000000000..d98a6598e0f9 --- /dev/null +++ b/.github/workflows/platform-fullstack-ci.yml @@ -0,0 +1,132 @@ +name: AutoGPT Platform - Frontend CI + +on: + push: + branches: [master, dev] + paths: + - ".github/workflows/platform-fullstack-ci.yml" + - "autogpt_platform/**" + pull_request: + paths: + - ".github/workflows/platform-fullstack-ci.yml" + - "autogpt_platform/**" + merge_group: + +defaults: + run: + shell: bash + working-directory: autogpt_platform/frontend + +jobs: + setup: + runs-on: ubuntu-latest + outputs: + cache-key: ${{ steps.cache-key.outputs.key }} + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "21" + + - name: Enable corepack + run: corepack enable + + - name: Generate cache key + id: cache-key + run: echo "key=${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ steps.cache-key.outputs.key }} + restore-keys: | + ${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }} + ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + types: + runs-on: ubuntu-latest + needs: setup + strategy: + fail-fast: false + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "21" + + - name: Enable corepack + run: corepack enable + + - name: Copy default supabase .env + run: | + cp ../.env.default ../.env + + - name: Copy backend .env + run: | + cp ../backend/.env.default ../backend/.env + + - name: Run docker compose + run: | + docker compose -f ../docker-compose.yml --profile local --profile deps_backend up -d + + - name: Restore dependencies cache + uses: actions/cache@v4 + with: + path: ~/.pnpm-store + key: ${{ needs.setup.outputs.cache-key }} + restore-keys: | + ${{ runner.os }}-pnpm- + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Setup .env + run: cp .env.default .env + + - name: Wait for services to be ready + run: | + echo "Waiting for rest_server to be ready..." + timeout 60 sh -c 'until curl -f http://localhost:8006/health 2>/dev/null; do sleep 2; done' || echo "Rest server health check timeout, continuing..." + echo "Waiting for database to be ready..." + timeout 60 sh -c 'until docker compose -f ../docker-compose.yml exec -T db pg_isready -U postgres 2>/dev/null; do sleep 2; done' || echo "Database ready check timeout, continuing..." + + - name: Generate API queries + run: pnpm generate:api:force + + - name: Check for API schema changes + run: | + if ! git diff --exit-code src/app/api/openapi.json; then + echo "❌ API schema changes detected in src/app/api/openapi.json" + echo "" + echo "The openapi.json file has been modified after running 'pnpm generate:api-all'." + echo "This usually means changes have been made in the BE endpoints without updating the Frontend." + echo "The API schema is now out of sync with the Front-end queries." + echo "" + echo "To fix this:" + echo "1. Pull the backend 'docker compose pull && docker compose up -d --build --force-recreate'" + echo "2. Run 'pnpm generate:api' locally" + echo "3. Run 'pnpm types' locally" + echo "4. Fix any TypeScript errors that may have been introduced" + echo "5. Commit and push your changes" + echo "" + exit 1 + else + echo "✅ No API schema changes detected" + fi + + - name: Run Typescript checks + run: pnpm types diff --git a/.github/workflows/platform-market-ci.yml b/.github/workflows/platform-market-ci.yml deleted file mode 100644 index c4ba56f9632c..000000000000 --- a/.github/workflows/platform-market-ci.yml +++ /dev/null @@ -1,126 +0,0 @@ -name: AutoGPT Platform - Backend CI - -on: - push: - branches: [master, dev, ci-test*] - paths: - - ".github/workflows/platform-market-ci.yml" - - "autogpt_platform/market/**" - pull_request: - branches: [master, dev, release-*] - paths: - - ".github/workflows/platform-market-ci.yml" - - "autogpt_platform/market/**" - merge_group: - -concurrency: - group: ${{ format('backend-ci-{0}', github.head_ref && format('{0}-{1}', github.event_name, github.event.pull_request.number) || github.sha) }} - cancel-in-progress: ${{ startsWith(github.event_name, 'pull_request') }} - -defaults: - run: - shell: bash - working-directory: autogpt_platform/market - -jobs: - test: - permissions: - contents: read - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - python-version: ["3.10"] - runs-on: ubuntu-latest - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: true - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Setup Supabase - uses: supabase/setup-cli@v1 - with: - version: latest - - - id: get_date - name: Get date - run: echo "date=$(date +'%Y-%m-%d')" >> $GITHUB_OUTPUT - - - name: Set up Python dependency cache - uses: actions/cache@v4 - with: - path: ~/.cache/pypoetry - key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/market/poetry.lock') }} - - - name: Install Poetry (Unix) - run: | - curl -sSL https://install.python-poetry.org | python3 - - - if [ "${{ runner.os }}" = "macOS" ]; then - PATH="$HOME/.local/bin:$PATH" - echo "$HOME/.local/bin" >> $GITHUB_PATH - fi - - - name: Install Python dependencies - run: poetry install - - - name: Generate Prisma Client - run: poetry run prisma generate - - - id: supabase - name: Start Supabase - working-directory: . - run: | - supabase init - supabase start --exclude postgres-meta,realtime,storage-api,imgproxy,inbucket,studio,edge-runtime,logflare,vector,supavisor - supabase status -o env | sed 's/="/=/; s/"$//' >> $GITHUB_OUTPUT - # outputs: - # DB_URL, API_URL, GRAPHQL_URL, ANON_KEY, SERVICE_ROLE_KEY, JWT_SECRET - - - name: Run Database Migrations - run: poetry run prisma migrate dev --name updates - env: - DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }} - - - id: lint - name: Run Linter - run: poetry run lint - - # Tests comment out because they do not work with prisma mock, nor have they been updated since they were created - # - name: Run pytest with coverage - # run: | - # if [[ "${{ runner.debug }}" == "1" ]]; then - # poetry run pytest -s -vv -o log_cli=true -o log_cli_level=DEBUG test - # else - # poetry run pytest -s -vv test - # fi - # if: success() || (failure() && steps.lint.outcome == 'failure') - # env: - # LOG_LEVEL: ${{ runner.debug && 'DEBUG' || 'INFO' }} - # DATABASE_URL: ${{ steps.supabase.outputs.DB_URL }} - # SUPABASE_URL: ${{ steps.supabase.outputs.API_URL }} - # SUPABASE_SERVICE_ROLE_KEY: ${{ steps.supabase.outputs.SERVICE_ROLE_KEY }} - # SUPABASE_JWT_SECRET: ${{ steps.supabase.outputs.JWT_SECRET }} - # REDIS_HOST: 'localhost' - # REDIS_PORT: '6379' - # REDIS_PASSWORD: 'testpassword' - - env: - CI: true - PLAIN_OUTPUT: True - RUN_ENV: local - PORT: 8080 - - # - name: Upload coverage reports to Codecov - # uses: codecov/codecov-action@v4 - # with: - # token: ${{ secrets.CODECOV_TOKEN }} - # flags: backend,${{ runner.os }} diff --git a/.github/workflows/repo-close-stale-issues.yml b/.github/workflows/repo-close-stale-issues.yml index fa5248d08233..a9f183d77566 100644 --- a/.github/workflows/repo-close-stale-issues.yml +++ b/.github/workflows/repo-close-stale-issues.yml @@ -16,7 +16,7 @@ jobs: # operations-per-run: 5000 stale-issue-message: > This issue has automatically been marked as _stale_ because it has not had - any activity in the last 50 days. You can _unstale_ it by commenting or + any activity in the last 170 days. You can _unstale_ it by commenting or removing the label. Otherwise, this issue will be closed in 10 days. stale-pr-message: > This pull request has automatically been marked as _stale_ because it has @@ -25,7 +25,7 @@ jobs: close-issue-message: > This issue was closed automatically because it has been stale for 10 days with no activity. - days-before-stale: 50 + days-before-stale: 170 days-before-close: 10 # Do not touch meta issues: exempt-issue-labels: meta,fridge,project management diff --git a/.github/workflows/scripts/get_package_version_from_lockfile.py b/.github/workflows/scripts/get_package_version_from_lockfile.py new file mode 100644 index 000000000000..3ee99d6696f3 --- /dev/null +++ b/.github/workflows/scripts/get_package_version_from_lockfile.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +import sys + +if sys.version_info < (3, 11): + print("Python version 3.11 or higher required") + sys.exit(1) + +import tomllib + + +def get_package_version(package_name: str, lockfile_path: str) -> str | None: + """Extract package version from poetry.lock file.""" + try: + if lockfile_path == "-": + data = tomllib.load(sys.stdin.buffer) + else: + with open(lockfile_path, "rb") as f: + data = tomllib.load(f) + except FileNotFoundError: + print(f"Error: File '{lockfile_path}' not found", file=sys.stderr) + sys.exit(1) + except tomllib.TOMLDecodeError as e: + print(f"Error parsing TOML file: {e}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"Error reading file: {e}", file=sys.stderr) + sys.exit(1) + + # Look for the package in the packages list + packages = data.get("package", []) + for package in packages: + if package.get("name", "").lower() == package_name.lower(): + return package.get("version") + + return None + + +def main(): + if len(sys.argv) not in (2, 3): + print( + "Usages: python get_package_version_from_lockfile.py [poetry.lock path]\n" + " cat poetry.lock | python get_package_version_from_lockfile.py -", + file=sys.stderr, + ) + sys.exit(1) + + package_name = sys.argv[1] + lockfile_path = sys.argv[2] if len(sys.argv) == 3 else "poetry.lock" + + version = get_package_version(package_name, lockfile_path) + + if version: + print(version) + else: + print(f"Package '{package_name}' not found in {lockfile_path}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/.gitignore b/.gitignore index d00ab276ce1f..15160be56ee4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ classic/original_autogpt/*.json auto_gpt_workspace/* *.mpeg .env +# Root .env files +/.env azure.yaml .vscode .idea/* @@ -121,7 +123,6 @@ celerybeat.pid # Environments .direnv/ -.env .venv env/ venv*/ @@ -165,7 +166,7 @@ package-lock.json # Allow for locally private items # private -pri* +pri* # ignore ig* .github_access_token @@ -176,3 +177,4 @@ autogpt_platform/backend/settings.py *.ign.* .test-contents +.claude/settings.local.json diff --git a/.gitmodules b/.gitmodules index 4db81f42c094..112960b5b57c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "classic/forge/tests/vcr_cassettes"] path = classic/forge/tests/vcr_cassettes url = https://github.com/Significant-Gravitas/Auto-GPT-test-cassettes -[submodule "autogpt_platform/supabase"] - path = autogpt_platform/supabase - url = https://github.com/supabase/supabase.git diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 724eb8ef1c52..39ec659ef9e8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,7 +17,7 @@ repos: name: Detect secrets description: Detects high entropy strings that are likely to be passwords. files: ^autogpt_platform/ - stages: [push] + stages: [pre-push] - repo: local # For proper type checking, all dependencies need to be up-to-date. @@ -110,7 +110,7 @@ repos: - id: isort name: Lint (isort) - AutoGPT Platform - Backend alias: isort-platform-backend - entry: poetry -C autogpt_platform/backend run isort -p backend + entry: poetry -P autogpt_platform/backend run isort -p backend files: ^autogpt_platform/backend/ types: [file, python] language: system @@ -118,7 +118,7 @@ repos: - id: isort name: Lint (isort) - Classic - AutoGPT alias: isort-classic-autogpt - entry: poetry -C classic/original_autogpt run isort -p autogpt + entry: poetry -P classic/original_autogpt run isort -p autogpt files: ^classic/original_autogpt/ types: [file, python] language: system @@ -126,7 +126,7 @@ repos: - id: isort name: Lint (isort) - Classic - Forge alias: isort-classic-forge - entry: poetry -C classic/forge run isort -p forge + entry: poetry -P classic/forge run isort -p forge files: ^classic/forge/ types: [file, python] language: system @@ -134,13 +134,13 @@ repos: - id: isort name: Lint (isort) - Classic - Benchmark alias: isort-classic-benchmark - entry: poetry -C classic/benchmark run isort -p agbenchmark + entry: poetry -P classic/benchmark run isort -p agbenchmark files: ^classic/benchmark/ types: [file, python] language: system - repo: https://github.com/psf/black - rev: 23.12.1 + rev: 24.10.0 # Black has sensible defaults, doesn't need package context, and ignores # everything in .gitignore, so it works fine without any config or arguments. hooks: @@ -170,6 +170,16 @@ repos: files: ^classic/benchmark/(agbenchmark|tests)/((?!reports).)*[/.] args: [--config=classic/benchmark/.flake8] + - repo: local + hooks: + - id: prettier + name: Format (Prettier) - AutoGPT Platform - Frontend + alias: format-platform-frontend + entry: bash -c 'cd autogpt_platform/frontend && npx prettier --write $(echo "$@" | sed "s|autogpt_platform/frontend/||g")' -- + files: ^autogpt_platform/frontend/ + types: [file] + language: system + - repo: local # To have watertight type checking, we check *all* the files in an affected # project. To trigger on poetry.lock we also reset the file `types` filter. @@ -178,7 +188,6 @@ repos: name: Typecheck - AutoGPT Platform - Backend alias: pyright-platform-backend entry: poetry -C autogpt_platform/backend run pyright - args: [-p, autogpt_platform/backend, autogpt_platform/backend] # include forge source (since it's a path dependency) but exclude *_test.py files: files: ^autogpt_platform/(backend/((backend|test)/|(\w+\.py|poetry\.lock)$)|autogpt_libs/(autogpt_libs/.*(? - [contribution guide]: https://github.com/Significant-Gravitas/AutoGPT/wiki/Contributing [wiki]: https://github.com/Significant-Gravitas/AutoGPT/wiki [roadmap]: https://github.com/Significant-Gravitas/AutoGPT/discussions/6971 diff --git a/LICENSE b/LICENSE index 52c6e9a8d528..f141042fa4c9 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,197 @@ -All portions of this repository are under one of two licenses. The majority of the AutoGPT repository is under the MIT License below. The autogpt_platform folder is under the -Polyform Shield License. +All portions of this repository are under one of two licenses. +- Everything inside the autogpt_platform folder is under the Polyform Shield License. +- Everything outside the autogpt_platform folder is under the MIT License. + +More info: + +**Polyform Shield License:** +Code and content within the `autogpt_platform` folder is licensed under the Polyform Shield License. This new project is our in-developlemt platform for building, deploying and managing agents. +Read more about this effort here: https://agpt.co/blog/introducing-the-autogpt-platform + +**MIT License:** +All other portions of the AutoGPT repository (i.e., everything outside the `autogpt_platform` folder) are licensed under the MIT License. This includes: +- The Original, stand-alone AutoGPT Agent +- Forge: https://github.com/Significant-Gravitas/AutoGPT/tree/master/classic/forge +- AG Benchmark: https://github.com/Significant-Gravitas/AutoGPT/tree/master/classic/benchmark +- AutoGPT Classic GUI: https://github.com/Significant-Gravitas/AutoGPT/tree/master/classic/frontend. + +We also publish additional work under the MIT Licence in other repositories, such as GravitasML (https://github.com/Significant-Gravitas/gravitasml) which is developed for and used in the AutoGPT Platform, and our [Code Ability](https://github.com/Significant-Gravitas/AutoGPT-Code-Ability) project. + +Both licences are available to read below: + +===================================================== +----------------------------------------------------- +===================================================== + +# PolyForm Shield License 1.0.0 + + + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the +software to do everything you might do with the software +that would otherwise infringe the licensor's copyright +in it for any permitted purpose. However, you may +only distribute the software according to [Distribution +License](#distribution-license) and make changes or new works +based on the software according to [Changes and New Works +License](#changes-and-new-works-license). + +## Distribution License + +The licensor grants you an additional copyright license +to distribute copies of the software. Your license +to distribute covers distributing the software with +changes and new works permitted by [Changes and New Works +License](#changes-and-new-works-license). + +## Notices + +You must ensure that anyone who gets a copy of any part of +the software from you also gets a copy of these terms or the +URL for them above, as well as copies of any plain-text lines +beginning with `Required Notice:` that the licensor provided +with the software. For example: + +> Required Notice: Copyright Yoyodyne, Inc. (http://example.com) + +## Changes and New Works License + +The licensor grants you an additional copyright license to +make changes and new works based on the software for any +permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Noncompete + +Any purpose is a permitted purpose, except for providing any +product that competes with the software or any product the +licensor or any of its affiliates provides using the software. + +## Competition + +Goods and services compete even when they provide functionality +through different kinds of interfaces or for different technical +platforms. Applications can compete with services, libraries +with plugins, frameworks with development tools, and so on, +even if they're written in different programming languages +or for different computer architectures. Goods and services +compete even when provided free of charge. If you market a +product as a practical substitute for the software or another +product, it definitely competes. + +## New Products + +If you are using the software to provide a product that does +not compete, but the licensor or any of its affiliates brings +your product into competition by providing a new version of +the software or another product using the software, you may +continue using versions of the software available under these +terms beforehand to provide your competing product, but not +any later versions. + +## Discontinued Products + +You may begin using the software to compete with a product +or service that the licensor or any of its affiliates has +stopped providing, unless the licensor includes a plain-text +line beginning with `Licensor Line of Business:` with the +software that mentions that line of business. For example: + +> Licensor Line of Business: YoyodyneCMS Content Management +System (http://example.com/cms) + +## Sales of Business + +If the licensor or any of its affiliates sells a line of +business developing the software or using the software +to provide a product, the buyer can also enforce +[Noncompete](#noncompete) for that product. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +A **product** can be a good or service, or a combination +of them. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +its affiliates. + +**Affiliates** means the other organizations than an +organization has control over, is under the control of, or is +under common control with. + +**Control** means ownership of substantially all the assets of +an entity, or the power to direct its management and policies +by vote, contract, or otherwise. Control can be direct or +indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. + +===================================================== +----------------------------------------------------- +===================================================== MIT License diff --git a/README.md b/README.md index 9cbaefdb2515..3572fe318be3 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,82 @@ # AutoGPT: Build, Deploy, and Run AI Agents -[![Discord Follow](https://dcbadge.vercel.app/api/server/autogpt?style=flat)](https://discord.gg/autogpt)   +[![Discord Follow](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Fautogpt%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&label=total%20members&logo=discord&logoColor=white&color=7289da)](https://discord.gg/autogpt)   [![Twitter Follow](https://img.shields.io/twitter/follow/Auto_GPT?style=social)](https://twitter.com/Auto_GPT)   -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + + +[Deutsch](https://zdoc.app/de/Significant-Gravitas/AutoGPT) | +[Español](https://zdoc.app/es/Significant-Gravitas/AutoGPT) | +[français](https://zdoc.app/fr/Significant-Gravitas/AutoGPT) | +[日本語](https://zdoc.app/ja/Significant-Gravitas/AutoGPT) | +[한국어](https://zdoc.app/ko/Significant-Gravitas/AutoGPT) | +[Português](https://zdoc.app/pt/Significant-Gravitas/AutoGPT) | +[Русский](https://zdoc.app/ru/Significant-Gravitas/AutoGPT) | +[中文](https://zdoc.app/zh/Significant-Gravitas/AutoGPT) **AutoGPT** is a powerful platform that allows you to create, deploy, and manage continuous AI agents that automate complex workflows. ## Hosting Options - - Download to self-host - - [Join the Waitlist](https://bit.ly/3ZDijAI) for the cloud-hosted beta + - Download to self-host (Free!) + - [Join the Waitlist](https://bit.ly/3ZDijAI) for the cloud-hosted beta (Closed Beta - Public release Coming Soon!) -## How to Setup for Self-Hosting +## How to Self-Host the AutoGPT Platform > [!NOTE] > Setting up and hosting the AutoGPT Platform yourself is a technical process. > If you'd rather something that just works, we recommend [joining the waitlist](https://bit.ly/3ZDijAI) for the cloud-hosted beta. -https://github.com/user-attachments/assets/d04273a5-b36a-4a37-818e-f631ce72d603 +### System Requirements + +Before proceeding with the installation, ensure your system meets the following requirements: + +#### Hardware Requirements +- CPU: 4+ cores recommended +- RAM: Minimum 8GB, 16GB recommended +- Storage: At least 10GB of free space + +#### Software Requirements +- Operating Systems: + - Linux (Ubuntu 20.04 or newer recommended) + - macOS (10.15 or newer) + - Windows 10/11 with WSL2 +- Required Software (with minimum versions): + - Docker Engine (20.10.0 or newer) + - Docker Compose (2.0.0 or newer) + - Git (2.30 or newer) + - Node.js (16.x or newer) + - npm (8.x or newer) + - VSCode (1.60 or newer) or any modern code editor + +#### Network Requirements +- Stable internet connection +- Access to required ports (will be configured in Docker) +- Ability to make outbound HTTPS connections + +### Updated Setup Instructions: +We've moved to a fully maintained and regularly updated documentation site. + +👉 [Follow the official self-hosting guide here](https://docs.agpt.co/platform/getting-started/) + This tutorial assumes you have Docker, VSCode, git and npm installed. +--- + +#### ⚡ Quick Setup with One-Line Script (Recommended for Local Hosting) + +Skip the manual steps and get started in minutes using our automatic setup script. + +For macOS/Linux: +``` +curl -fsSL https://setup.agpt.co/install.sh -o install.sh && bash install.sh +``` + +For Windows (PowerShell): +``` +powershell -c "iwr https://setup.agpt.co/install.bat -o install.bat; ./install.bat" +``` + +This will install dependencies, configure Docker, and launch your local instance — all in one go. + ### 🧱 AutoGPT Frontend The AutoGPT frontend is where users interact with our powerful AI automation platform. It offers multiple ways to engage with and leverage our AI agents. This is the interface where you'll bring your AI automation ideas to life: @@ -65,7 +123,17 @@ Here are two examples of what you can do with AutoGPT: These examples show just a glimpse of what you can achieve with AutoGPT! You can create customized workflows to build agents for any use case. --- -### Mission and Licencing + +### **License Overview:** + +🛡️ **Polyform Shield License:** +All code and content within the `autogpt_platform` folder is licensed under the Polyform Shield License. This new project is our in-developlemt platform for building, deploying and managing agents.
_[Read more about this effort](https://agpt.co/blog/introducing-the-autogpt-platform)_ + +🦉 **MIT License:** +All other portions of the AutoGPT repository (i.e., everything outside the `autogpt_platform` folder) are licensed under the MIT License. This includes the original stand-alone AutoGPT Agent, along with projects such as [Forge](https://github.com/Significant-Gravitas/AutoGPT/tree/master/classic/forge), [agbenchmark](https://github.com/Significant-Gravitas/AutoGPT/tree/master/classic/benchmark) and the [AutoGPT Classic GUI](https://github.com/Significant-Gravitas/AutoGPT/tree/master/classic/frontend).
We also publish additional work under the MIT Licence in other repositories, such as [GravitasML](https://github.com/Significant-Gravitas/gravitasml) which is developed for and used in the AutoGPT Platform. See also our MIT Licenced [Code Ability](https://github.com/Significant-Gravitas/AutoGPT-Code-Ability) project. + +--- +### Mission Our mission is to provide the tools, so that you can focus on what matters: - 🏗️ **Building** - Lay the foundation for something amazing. @@ -78,14 +146,6 @@ Be part of the revolution! **AutoGPT** is here to stay, at the forefront of AI i  |  **🚀 [Contributing](CONTRIBUTING.md)** -**Licensing:** - -MIT License: The majority of the AutoGPT repository is under the MIT License. - -Polyform Shield License: This license applies to the autogpt_platform folder. - -For more information, see https://agpt.co/blog/introducing-the-autogpt-platform - --- ## 🤖 AutoGPT Classic > Below is information about the classic version of AutoGPT. @@ -148,7 +208,7 @@ Just clone the repo, install dependencies with `./run setup`, and you should be [![Join us on Discord](https://invidget.switchblade.xyz/autogpt)](https://discord.gg/autogpt) -To report a bug or request a feature, create a [GitHub Issue](https://github.com/Significant-Gravitas/AutoGPT/issues/new/choose). Please ensure someone else hasn’t created an issue for the same topic. +To report a bug or request a feature, create a [GitHub Issue](https://github.com/Significant-Gravitas/AutoGPT/issues/new/choose). Please ensure someone else hasn't created an issue for the same topic. ## 🤝 Sister projects diff --git a/SECURITY.md b/SECURITY.md index 1bacc8ef83d2..45705d710669 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -20,6 +20,7 @@ Instead, please report them via: - Please provide detailed reports with reproducible steps - Include the version/commit hash where you discovered the vulnerability - Allow us a 90-day security fix window before any public disclosure +- After patch is released, allow 30 days for users to update before public disclosure (for a total of 120 days max between update time and fix time) - Share any potential mitigations or workarounds if known ## Supported Versions diff --git a/autogpt_platform/.env.default b/autogpt_platform/.env.default new file mode 100644 index 000000000000..bb74500874d3 --- /dev/null +++ b/autogpt_platform/.env.default @@ -0,0 +1,123 @@ +############ +# Secrets +# YOU MUST CHANGE THESE BEFORE GOING INTO PRODUCTION +############ + +POSTGRES_PASSWORD=your-super-secret-and-long-postgres-password +JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long +ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE +SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q +DASHBOARD_USERNAME=supabase +DASHBOARD_PASSWORD=this_password_is_insecure_and_should_be_updated +SECRET_KEY_BASE=UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq +VAULT_ENC_KEY=your-encryption-key-32-chars-min + + +############ +# Database - You can change these to any PostgreSQL database that has logical replication enabled. +############ + +POSTGRES_HOST=db +POSTGRES_DB=postgres +POSTGRES_PORT=5432 +# default user is postgres + + +############ +# Supavisor -- Database pooler +############ +POOLER_PROXY_PORT_TRANSACTION=6543 +POOLER_DEFAULT_POOL_SIZE=20 +POOLER_MAX_CLIENT_CONN=100 +POOLER_TENANT_ID=your-tenant-id + + +############ +# API Proxy - Configuration for the Kong Reverse proxy. +############ + +KONG_HTTP_PORT=8000 +KONG_HTTPS_PORT=8443 + + +############ +# API - Configuration for PostgREST. +############ + +PGRST_DB_SCHEMAS=public,storage,graphql_public + + +############ +# Auth - Configuration for the GoTrue authentication server. +############ + +## General +SITE_URL=http://localhost:3000 +ADDITIONAL_REDIRECT_URLS= +JWT_EXPIRY=3600 +DISABLE_SIGNUP=false +API_EXTERNAL_URL=http://localhost:8000 + +## Mailer Config +MAILER_URLPATHS_CONFIRMATION="/auth/v1/verify" +MAILER_URLPATHS_INVITE="/auth/v1/verify" +MAILER_URLPATHS_RECOVERY="/auth/v1/verify" +MAILER_URLPATHS_EMAIL_CHANGE="/auth/v1/verify" + +## Email auth +ENABLE_EMAIL_SIGNUP=true +ENABLE_EMAIL_AUTOCONFIRM=false +SMTP_ADMIN_EMAIL=admin@example.com +SMTP_HOST=supabase-mail +SMTP_PORT=2500 +SMTP_USER=fake_mail_user +SMTP_PASS=fake_mail_password +SMTP_SENDER_NAME=fake_sender +ENABLE_ANONYMOUS_USERS=false + +## Phone auth +ENABLE_PHONE_SIGNUP=true +ENABLE_PHONE_AUTOCONFIRM=true + + +############ +# Studio - Configuration for the Dashboard +############ + +STUDIO_DEFAULT_ORGANIZATION=Default Organization +STUDIO_DEFAULT_PROJECT=Default Project + +STUDIO_PORT=3000 +# replace if you intend to use Studio outside of localhost +SUPABASE_PUBLIC_URL=http://localhost:8000 + +# Enable webp support +IMGPROXY_ENABLE_WEBP_DETECTION=true + +# Add your OpenAI API key to enable SQL Editor Assistant +OPENAI_API_KEY= + + +############ +# Functions - Configuration for Functions +############ +# NOTE: VERIFY_JWT applies to all functions. Per-function VERIFY_JWT is not supported yet. +FUNCTIONS_VERIFY_JWT=false + + +############ +# Logs - Configuration for Logflare +# Please refer to https://supabase.com/docs/reference/self-hosting-analytics/introduction +############ + +LOGFLARE_LOGGER_BACKEND_API_KEY=your-super-secret-and-long-logflare-key + +# Change vector.toml sinks to reflect this change +LOGFLARE_API_KEY=your-super-secret-and-long-logflare-key + +# Docker socket location - this value will differ depending on your OS +DOCKER_SOCKET_LOCATION=/var/run/docker.sock + +# Google Cloud Project details +GOOGLE_PROJECT_ID=GOOGLE_PROJECT_ID +GOOGLE_PROJECT_NUMBER=GOOGLE_PROJECT_NUMBER diff --git a/autogpt_platform/CLAUDE.md b/autogpt_platform/CLAUDE.md new file mode 100644 index 000000000000..d6f69d67998b --- /dev/null +++ b/autogpt_platform/CLAUDE.md @@ -0,0 +1,230 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository Overview + +AutoGPT Platform is a monorepo containing: + +- **Backend** (`/backend`): Python FastAPI server with async support +- **Frontend** (`/frontend`): Next.js React application +- **Shared Libraries** (`/autogpt_libs`): Common Python utilities + +## Essential Commands + +### Backend Development + +```bash +# Install dependencies +cd backend && poetry install + +# Run database migrations +poetry run prisma migrate dev + +# Start all services (database, redis, rabbitmq, clamav) +docker compose up -d + +# Run the backend server +poetry run serve + +# Run tests +poetry run test + +# Run specific test +poetry run pytest path/to/test_file.py::test_function_name + +# Run block tests (tests that validate all blocks work correctly) +poetry run pytest backend/blocks/test/test_block.py -xvs + +# Run tests for a specific block (e.g., GetCurrentTimeBlock) +poetry run pytest 'backend/blocks/test/test_block.py::test_available_blocks[GetCurrentTimeBlock]' -xvs + +# Lint and format +# prefer format if you want to just "fix" it and only get the errors that can't be autofixed +poetry run format # Black + isort +poetry run lint # ruff +``` + +More details can be found in TESTING.md + +#### Creating/Updating Snapshots + +When you first write a test or when the expected output changes: + +```bash +poetry run pytest path/to/test.py --snapshot-update +``` + +⚠️ **Important**: Always review snapshot changes before committing! Use `git diff` to verify the changes are expected. + +### Frontend Development + +```bash +# Install dependencies +cd frontend && npm install + +# Start development server +npm run dev + +# Run E2E tests +npm run test + +# Run Storybook for component development +npm run storybook + +# Build production +npm run build + +# Type checking +npm run types +``` + +## Architecture Overview + +### Backend Architecture + +- **API Layer**: FastAPI with REST and WebSocket endpoints +- **Database**: PostgreSQL with Prisma ORM, includes pgvector for embeddings +- **Queue System**: RabbitMQ for async task processing +- **Execution Engine**: Separate executor service processes agent workflows +- **Authentication**: JWT-based with Supabase integration +- **Security**: Cache protection middleware prevents sensitive data caching in browsers/proxies + +### Frontend Architecture + +- **Framework**: Next.js App Router with React Server Components +- **State Management**: React hooks + Supabase client for real-time updates +- **Workflow Builder**: Visual graph editor using @xyflow/react +- **UI Components**: Radix UI primitives with Tailwind CSS styling +- **Feature Flags**: LaunchDarkly integration + +### Key Concepts + +1. **Agent Graphs**: Workflow definitions stored as JSON, executed by the backend +2. **Blocks**: Reusable components in `/backend/blocks/` that perform specific tasks +3. **Integrations**: OAuth and API connections stored per user +4. **Store**: Marketplace for sharing agent templates +5. **Virus Scanning**: ClamAV integration for file upload security + +### Testing Approach + +- Backend uses pytest with snapshot testing for API responses +- Test files are colocated with source files (`*_test.py`) +- Frontend uses Playwright for E2E tests +- Component testing via Storybook + +### Database Schema + +Key models (defined in `/backend/schema.prisma`): + +- `User`: Authentication and profile data +- `AgentGraph`: Workflow definitions with version control +- `AgentGraphExecution`: Execution history and results +- `AgentNode`: Individual nodes in a workflow +- `StoreListing`: Marketplace listings for sharing agents + +### Environment Configuration + +#### Configuration Files + +- **Backend**: `/backend/.env.default` (defaults) → `/backend/.env` (user overrides) +- **Frontend**: `/frontend/.env.default` (defaults) → `/frontend/.env` (user overrides) +- **Platform**: `/.env.default` (Supabase/shared defaults) → `/.env` (user overrides) + +#### Docker Environment Loading Order + +1. `.env.default` files provide base configuration (tracked in git) +2. `.env` files provide user-specific overrides (gitignored) +3. Docker Compose `environment:` sections provide service-specific overrides +4. Shell environment variables have highest precedence + +#### Key Points + +- All services use hardcoded defaults in docker-compose files (no `${VARIABLE}` substitutions) +- The `env_file` directive loads variables INTO containers at runtime +- Backend/Frontend services use YAML anchors for consistent configuration +- Supabase services (`db/docker/docker-compose.yml`) follow the same pattern + +### Common Development Tasks + +**Adding a new block:** + +1. Create new file in `/backend/backend/blocks/` +2. Inherit from `Block` base class +3. Define input/output schemas +4. Implement `run` method +5. Register in block registry +6. Generate the block uuid using `uuid.uuid4()` + +Note: when making many new blocks analyze the interfaces for each of these blocks and picture if they would go well together in a graph based editor or would they struggle to connect productively? +ex: do the inputs and outputs tie well together? + +**Modifying the API:** + +1. Update route in `/backend/backend/server/routers/` +2. Add/update Pydantic models in same directory +3. Write tests alongside the route file +4. Run `poetry run test` to verify + +**Frontend feature development:** + +1. Components go in `/frontend/src/components/` +2. Use existing UI components from `/frontend/src/components/ui/` +3. Add Storybook stories for new components +4. Test with Playwright if user-facing + +### Security Implementation + +**Cache Protection Middleware:** + +- Located in `/backend/backend/server/middleware/security.py` +- Default behavior: Disables caching for ALL endpoints with `Cache-Control: no-store, no-cache, must-revalidate, private` +- Uses an allow list approach - only explicitly permitted paths can be cached +- Cacheable paths include: static assets (`/static/*`, `/_next/static/*`), health checks, public store pages, documentation +- Prevents sensitive data (auth tokens, API keys, user data) from being cached by browsers/proxies +- To allow caching for a new endpoint, add it to `CACHEABLE_PATHS` in the middleware +- Applied to both main API server and external API applications + +### Creating Pull Requests + +- Create the PR aginst the `dev` branch of the repository. +- Ensure the branch name is descriptive (e.g., `feature/add-new-block`)/ +- Use conventional commit messages (see below)/ +- Fill out the .github/PULL_REQUEST_TEMPLATE.md template as the PR description/ +- Run the github pre-commit hooks to ensure code quality. + +### Reviewing/Revising Pull Requests + +- When the user runs /pr-comments or tries to fetch them, also run gh api /repos/Significant-Gravitas/AutoGPT/pulls/[issuenum]/reviews to get the reviews +- Use gh api /repos/Significant-Gravitas/AutoGPT/pulls/[issuenum]/reviews/[review_id]/comments to get the review contents +- Use gh api /repos/Significant-Gravitas/AutoGPT/issues/9924/comments to get the pr specific comments + +### Conventional Commits + +Use this format for commit messages and Pull Request titles: + +**Conventional Commit Types:** + +- `feat`: Introduces a new feature to the codebase +- `fix`: Patches a bug in the codebase +- `refactor`: Code change that neither fixes a bug nor adds a feature; also applies to removing features +- `ci`: Changes to CI configuration +- `docs`: Documentation-only changes +- `dx`: Improvements to the developer experience + +**Recommended Base Scopes:** + +- `platform`: Changes affecting both frontend and backend +- `frontend` +- `backend` +- `infra` +- `blocks`: Modifications/additions of individual blocks + +**Subscope Examples:** + +- `backend/executor` +- `backend/db` +- `frontend/builder` (includes changes to the block UI component) +- `infra/prod` + +Use these scopes and subscopes for clarity and consistency in commit messages. diff --git a/autogpt_platform/LICENCE.txt b/autogpt_platform/LICENSE.md similarity index 100% rename from autogpt_platform/LICENCE.txt rename to autogpt_platform/LICENSE.md diff --git a/autogpt_platform/README.md b/autogpt_platform/README.md index 64e61e880c06..ee2ff9f75933 100644 --- a/autogpt_platform/README.md +++ b/autogpt_platform/README.md @@ -8,60 +8,35 @@ Welcome to the AutoGPT Platform - a powerful system for creating and running AI - Docker - Docker Compose V2 (comes with Docker Desktop, or can be installed separately) -- Node.js & NPM (for running the frontend application) ### Running the System To run the AutoGPT Platform, follow these steps: 1. Clone this repository to your local machine and navigate to the `autogpt_platform` directory within the repository: + ``` git clone cd AutoGPT/autogpt_platform ``` 2. Run the following command: - ``` - git submodule update --init --recursive - ``` - This command will initialize and update the submodules in the repository. The `supabase` folder will be cloned to the root directory. -3. Run the following command: ``` - cp supabase/docker/.env.example .env + cp .env.default .env ``` - This command will copy the `.env.example` file to `.env` in the `supabase/docker` directory. You can modify the `.env` file to add your own environment variables. -4. Run the following command: - ``` - docker compose up -d - ``` - This command will start all the necessary backend services defined in the `docker-compose.yml` file in detached mode. + This command will copy the `.env.default` file to `.env`. You can modify the `.env` file to add your own environment variables. -5. Navigate to `frontend` within the `autogpt_platform` directory: - ``` - cd frontend - ``` - You will need to run your frontend application separately on your local machine. +3. Run the following command: -6. Run the following command: ``` - cp .env.example .env.local + docker compose up -d ``` - This command will copy the `.env.example` file to `.env.local` in the `frontend` directory. You can modify the `.env.local` within this folder to add your own environment variables for the frontend application. -7. Run the following command: - ``` - npm install - npm run dev - ``` - This command will install the necessary dependencies and start the frontend application in development mode. - If you are using Yarn, you can run the following commands instead: - ``` - yarn install && yarn dev - ``` + This command will start all the necessary backend services defined in the `docker-compose.yml` file in detached mode. -8. Open your browser and navigate to `http://localhost:3000` to access the AutoGPT Platform frontend. +4. After all the services are in ready state, open your browser and navigate to `http://localhost:3000` to access the AutoGPT Platform frontend. ### Docker Compose Commands @@ -74,43 +49,52 @@ Here are some useful Docker Compose commands for managing your AutoGPT Platform: - `docker compose down`: Stop and remove containers, networks, and volumes. - `docker compose watch`: Watch for changes in your services and automatically update them. - ### Sample Scenarios Here are some common scenarios where you might use multiple Docker Compose commands: 1. Updating and restarting a specific service: + ``` docker compose build api_srv docker compose up -d --no-deps api_srv ``` + This rebuilds the `api_srv` service and restarts it without affecting other services. 2. Viewing logs for troubleshooting: + ``` docker compose logs -f api_srv ws_srv ``` + This shows and follows the logs for both `api_srv` and `ws_srv` services. 3. Scaling a service for increased load: + ``` docker compose up -d --scale executor=3 ``` + This scales the `executor` service to 3 instances to handle increased load. 4. Stopping the entire system for maintenance: + ``` docker compose stop docker compose rm -f docker compose pull docker compose up -d ``` + This stops all services, removes containers, pulls the latest images, and restarts the system. 5. Developing with live updates: + ``` docker compose watch ``` + This watches for changes in your code and automatically updates the relevant services. 6. Checking the status of services: @@ -121,7 +105,6 @@ Here are some common scenarios where you might use multiple Docker Compose comma These scenarios demonstrate how to use Docker Compose commands in combination to manage your AutoGPT Platform effectively. - ### Persisting Data To persist data for PostgreSQL and Redis, you can modify the `docker-compose.yml` file to add volumes. Here's how: @@ -149,3 +132,28 @@ To persist data for PostgreSQL and Redis, you can modify the `docker-compose.yml 3. Save the file and run `docker compose up -d` to apply the changes. This configuration will create named volumes for PostgreSQL and Redis, ensuring that your data persists across container restarts. + +### API Client Generation + +The platform includes scripts for generating and managing the API client: + +- `pnpm fetch:openapi`: Fetches the OpenAPI specification from the backend service (requires backend to be running on port 8006) +- `pnpm generate:api-client`: Generates the TypeScript API client from the OpenAPI specification using Orval +- `pnpm generate:api`: Runs both fetch and generate commands in sequence + +#### Manual API Client Updates + +If you need to update the API client after making changes to the backend API: + +1. Ensure the backend services are running: + + ``` + docker compose up -d + ``` + +2. Generate the updated API client: + ``` + pnpm generate:api + ``` + +This will fetch the latest OpenAPI specification and regenerate the TypeScript client code. diff --git a/autogpt_platform/autogpt_libs/README.md b/autogpt_platform/autogpt_libs/README.md index e2d6a5e67e20..6b929f2a4b8c 100644 --- a/autogpt_platform/autogpt_libs/README.md +++ b/autogpt_platform/autogpt_libs/README.md @@ -1,3 +1,3 @@ # AutoGPT Libs -This is a new project to store shared functionality across different services in NextGen AutoGPT (e.g. authentication) +This is a new project to store shared functionality across different services in the AutoGPT Platform (e.g. authentication) diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/api_key/key_manager.py b/autogpt_platform/autogpt_libs/autogpt_libs/api_key/key_manager.py index 257250a75393..0ac5f8793c61 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/api_key/key_manager.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/api_key/key_manager.py @@ -31,4 +31,5 @@ def verify_api_key(self, provided_key: str, stored_hash: str) -> bool: """Verify if a provided API key matches the stored hash.""" if not provided_key.startswith(self.PREFIX): return False - return hashlib.sha256(provided_key.encode()).hexdigest() == stored_hash + provided_hash = hashlib.sha256(provided_key.encode()).hexdigest() + return secrets.compare_digest(provided_hash, stored_hash) diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/auth/__init__.py b/autogpt_platform/autogpt_libs/autogpt_libs/auth/__init__.py index 5090cb4f035d..c1fa9d911352 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/auth/__init__.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/auth/__init__.py @@ -1,14 +1,13 @@ -from .config import Settings from .depends import requires_admin_user, requires_user from .jwt_utils import parse_jwt_token -from .middleware import auth_middleware +from .middleware import APIKeyValidator, auth_middleware from .models import User __all__ = [ - "Settings", "parse_jwt_token", "requires_user", "requires_admin_user", + "APIKeyValidator", "auth_middleware", "User", ] diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/auth/config.py b/autogpt_platform/autogpt_libs/autogpt_libs/auth/config.py index 1c7bc182908f..216aefc37d9b 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/auth/config.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/auth/config.py @@ -1,18 +1,11 @@ import os -from dotenv import load_dotenv - -load_dotenv() - class Settings: - JWT_SECRET_KEY: str = os.getenv("SUPABASE_JWT_SECRET", "") - ENABLE_AUTH: bool = os.getenv("ENABLE_AUTH", "false").lower() == "true" - JWT_ALGORITHM: str = "HS256" - - @property - def is_configured(self) -> bool: - return bool(self.JWT_SECRET_KEY) + def __init__(self): + self.JWT_SECRET_KEY: str = os.getenv("SUPABASE_JWT_SECRET", "") + self.ENABLE_AUTH: bool = os.getenv("ENABLE_AUTH", "false").lower() == "true" + self.JWT_ALGORITHM: str = "HS256" settings = Settings() diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/auth/depends.py b/autogpt_platform/autogpt_libs/autogpt_libs/auth/depends.py index 88409e413cea..d17a03269431 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/auth/depends.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/auth/depends.py @@ -1,6 +1,6 @@ import fastapi -from .config import Settings +from .config import settings from .middleware import auth_middleware from .models import DEFAULT_USER_ID, User @@ -17,7 +17,7 @@ def requires_admin_user( def verify_user(payload: dict | None, admin_only: bool) -> User: if not payload: - if Settings.ENABLE_AUTH: + if settings.ENABLE_AUTH: raise fastapi.HTTPException( status_code=401, detail="Authorization header is missing" ) diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/auth/middleware.py b/autogpt_platform/autogpt_libs/autogpt_libs/auth/middleware.py index 783e1b35beab..68151452d2a1 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/auth/middleware.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/auth/middleware.py @@ -1,26 +1,29 @@ +import inspect import logging +import secrets +from typing import Any, Callable, Optional -from fastapi import HTTPException, Request -from fastapi.security import HTTPBearer +from fastapi import HTTPException, Request, Security +from fastapi.security import APIKeyHeader, HTTPBearer +from starlette.status import HTTP_401_UNAUTHORIZED from .config import settings from .jwt_utils import parse_jwt_token -security = HTTPBearer() logger = logging.getLogger(__name__) +bearer_auth = HTTPBearer(auto_error=False) async def auth_middleware(request: Request): if not settings.ENABLE_AUTH: # If authentication is disabled, allow the request to proceed - logger.warn("Auth disabled") + logger.warning("Auth disabled") return {} - security = HTTPBearer() - credentials = await security(request) + credentials = await bearer_auth(request) if not credentials: - raise HTTPException(status_code=401, detail="Authorization header is missing") + raise HTTPException(status_code=401, detail="Not authenticated") try: payload = parse_jwt_token(credentials.credentials) @@ -29,3 +32,108 @@ async def auth_middleware(request: Request): except ValueError as e: raise HTTPException(status_code=401, detail=str(e)) return payload + + +class APIKeyValidator: + """ + Configurable API key validator that supports custom validation functions + for FastAPI applications. + + This class provides a flexible way to implement API key authentication with optional + custom validation logic. It can be used for simple token matching + or more complex validation scenarios like database lookups. + + Examples: + Simple token validation: + ```python + validator = APIKeyValidator( + header_name="X-API-Key", + expected_token="your-secret-token" + ) + + @app.get("/protected", dependencies=[Depends(validator.get_dependency())]) + def protected_endpoint(): + return {"message": "Access granted"} + ``` + + Custom validation with database lookup: + ```python + async def validate_with_db(api_key: str): + api_key_obj = await db.get_api_key(api_key) + return api_key_obj if api_key_obj and api_key_obj.is_active else None + + validator = APIKeyValidator( + header_name="X-API-Key", + validate_fn=validate_with_db + ) + ``` + + Args: + header_name (str): The name of the header containing the API key + expected_token (Optional[str]): The expected API key value for simple token matching + validate_fn (Optional[Callable]): Custom validation function that takes an API key + string and returns a boolean or object. Can be async. + error_status (int): HTTP status code to use for validation errors + error_message (str): Error message to return when validation fails + """ + + def __init__( + self, + header_name: str, + expected_token: Optional[str] = None, + validate_fn: Optional[Callable[[str], bool]] = None, + error_status: int = HTTP_401_UNAUTHORIZED, + error_message: str = "Invalid API key", + ): + # Create the APIKeyHeader as a class property + self.security_scheme = APIKeyHeader(name=header_name) + self.expected_token = expected_token + self.custom_validate_fn = validate_fn + self.error_status = error_status + self.error_message = error_message + + async def default_validator(self, api_key: str) -> bool: + if not self.expected_token: + raise ValueError( + "Expected Token Required to be set when uisng API Key Validator default validation" + ) + return secrets.compare_digest(api_key, self.expected_token) + + async def __call__( + self, request: Request, api_key: str = Security(APIKeyHeader) + ) -> Any: + if api_key is None: + raise HTTPException(status_code=self.error_status, detail="Missing API key") + + # Use custom validation if provided, otherwise use default equality check + validator = self.custom_validate_fn or self.default_validator + result = ( + await validator(api_key) + if inspect.iscoroutinefunction(validator) + else validator(api_key) + ) + + if not result: + raise HTTPException( + status_code=self.error_status, detail=self.error_message + ) + + # Store validation result in request state if it's not just a boolean + if result is not True: + request.state.api_key = result + + return result + + def get_dependency(self): + """ + Returns a callable dependency that FastAPI will recognize as a security scheme + """ + + async def validate_api_key( + request: Request, api_key: str = Security(self.security_scheme) + ) -> Any: + return await self(request, api_key) + + # This helps FastAPI recognize it as a security dependency + validate_api_key.__name__ = f"validate_{self.security_scheme.model.name}" + return validate_api_key diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/client.py b/autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/client.py deleted file mode 100644 index dde516c1d8fa..000000000000 --- a/autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/client.py +++ /dev/null @@ -1,167 +0,0 @@ -import asyncio -import contextlib -import logging -from functools import wraps -from typing import Any, Awaitable, Callable, Dict, Optional, TypeVar, Union, cast - -import ldclient -from fastapi import HTTPException -from ldclient import Context, LDClient -from ldclient.config import Config -from typing_extensions import ParamSpec - -from .config import SETTINGS - -logger = logging.getLogger(__name__) -logging.basicConfig(level=logging.DEBUG) - -P = ParamSpec("P") -T = TypeVar("T") - - -def get_client() -> LDClient: - """Get the LaunchDarkly client singleton.""" - return ldclient.get() - - -def initialize_launchdarkly() -> None: - sdk_key = SETTINGS.launch_darkly_sdk_key - logger.debug( - f"Initializing LaunchDarkly with SDK key: {'present' if sdk_key else 'missing'}" - ) - - if not sdk_key: - logger.warning("LaunchDarkly SDK key not configured") - return - - config = Config(sdk_key) - ldclient.set_config(config) - - if ldclient.get().is_initialized(): - logger.info("LaunchDarkly client initialized successfully") - else: - logger.error("LaunchDarkly client failed to initialize") - - -def shutdown_launchdarkly() -> None: - """Shutdown the LaunchDarkly client.""" - if ldclient.get().is_initialized(): - ldclient.get().close() - logger.info("LaunchDarkly client closed successfully") - - -def create_context( - user_id: str, additional_attributes: Optional[Dict[str, Any]] = None -) -> Context: - """Create LaunchDarkly context with optional additional attributes.""" - builder = Context.builder(str(user_id)).kind("user") - if additional_attributes: - for key, value in additional_attributes.items(): - builder.set(key, value) - return builder.build() - - -def feature_flag( - flag_key: str, - default: bool = False, -) -> Callable[ - [Callable[P, Union[T, Awaitable[T]]]], Callable[P, Union[T, Awaitable[T]]] -]: - """ - Decorator for feature flag protected endpoints. - """ - - def decorator( - func: Callable[P, Union[T, Awaitable[T]]], - ) -> Callable[P, Union[T, Awaitable[T]]]: - @wraps(func) - async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: - try: - user_id = kwargs.get("user_id") - if not user_id: - raise ValueError("user_id is required") - - if not get_client().is_initialized(): - logger.warning( - f"LaunchDarkly not initialized, using default={default}" - ) - is_enabled = default - else: - context = create_context(str(user_id)) - is_enabled = get_client().variation(flag_key, context, default) - - if not is_enabled: - raise HTTPException(status_code=404, detail="Feature not available") - - result = func(*args, **kwargs) - if asyncio.iscoroutine(result): - return await result - return cast(T, result) - except Exception as e: - logger.error(f"Error evaluating feature flag {flag_key}: {e}") - raise - - @wraps(func) - def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: - try: - user_id = kwargs.get("user_id") - if not user_id: - raise ValueError("user_id is required") - - if not get_client().is_initialized(): - logger.warning( - f"LaunchDarkly not initialized, using default={default}" - ) - is_enabled = default - else: - context = create_context(str(user_id)) - is_enabled = get_client().variation(flag_key, context, default) - - if not is_enabled: - raise HTTPException(status_code=404, detail="Feature not available") - - return cast(T, func(*args, **kwargs)) - except Exception as e: - logger.error(f"Error evaluating feature flag {flag_key}: {e}") - raise - - return cast( - Callable[P, Union[T, Awaitable[T]]], - async_wrapper if asyncio.iscoroutinefunction(func) else sync_wrapper, - ) - - return decorator - - -def percentage_rollout( - flag_key: str, - default: bool = False, -) -> Callable[ - [Callable[P, Union[T, Awaitable[T]]]], Callable[P, Union[T, Awaitable[T]]] -]: - """Decorator for percentage-based rollouts.""" - return feature_flag(flag_key, default) - - -def beta_feature( - flag_key: Optional[str] = None, - unauthorized_response: Any = {"message": "Not available in beta"}, -) -> Callable[ - [Callable[P, Union[T, Awaitable[T]]]], Callable[P, Union[T, Awaitable[T]]] -]: - """Decorator for beta features.""" - actual_key = f"beta-{flag_key}" if flag_key else "beta" - return feature_flag(actual_key, False) - - -@contextlib.contextmanager -def mock_flag_variation(flag_key: str, return_value: Any): - """Context manager for testing feature flags.""" - original_variation = get_client().variation - get_client().variation = lambda key, context, default: ( - return_value if key == flag_key else original_variation(key, context, default) - ) - try: - yield - finally: - get_client().variation = original_variation diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/client_test.py b/autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/client_test.py deleted file mode 100644 index 8fccfb28b501..000000000000 --- a/autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/client_test.py +++ /dev/null @@ -1,45 +0,0 @@ -import pytest -from ldclient import LDClient - -from autogpt_libs.feature_flag.client import feature_flag, mock_flag_variation - - -@pytest.fixture -def ld_client(mocker): - client = mocker.Mock(spec=LDClient) - mocker.patch("ldclient.get", return_value=client) - client.is_initialized.return_value = True - return client - - -@pytest.mark.asyncio -async def test_feature_flag_enabled(ld_client): - ld_client.variation.return_value = True - - @feature_flag("test-flag") - async def test_function(user_id: str): - return "success" - - result = test_function(user_id="test-user") - assert result == "success" - ld_client.variation.assert_called_once() - - -@pytest.mark.asyncio -async def test_feature_flag_unauthorized_response(ld_client): - ld_client.variation.return_value = False - - @feature_flag("test-flag") - async def test_function(user_id: str): - return "success" - - result = test_function(user_id="test-user") - assert result == {"error": "disabled"} - - -def test_mock_flag_variation(ld_client): - with mock_flag_variation("test-flag", True): - assert ld_client.variation("test-flag", None, False) - - with mock_flag_variation("test-flag", False): - assert ld_client.variation("test-flag", None, False) diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/config.py b/autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/config.py deleted file mode 100644 index e01c285d1e66..000000000000 --- a/autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/config.py +++ /dev/null @@ -1,15 +0,0 @@ -from pydantic import Field -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - launch_darkly_sdk_key: str = Field( - default="", - description="The Launch Darkly SDK key", - validation_alias="LAUNCH_DARKLY_SDK_KEY", - ) - - model_config = SettingsConfigDict(case_sensitive=True, extra="ignore") - - -SETTINGS = Settings() diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/logging/config.py b/autogpt_platform/autogpt_libs/autogpt_libs/logging/config.py index 10db444247c6..d424e6ba8c7b 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/logging/config.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/logging/config.py @@ -1,6 +1,8 @@ """Logging module for Auto-GPT.""" import logging +import os +import socket import sys from pathlib import Path @@ -8,7 +10,16 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from .filters import BelowLevelFilter -from .formatters import AGPTFormatter, StructuredLoggingFormatter +from .formatters import AGPTFormatter + +# Configure global socket timeout and gRPC keepalive to prevent deadlocks +# This must be done at import time before any gRPC connections are established +socket.setdefaulttimeout(30) # 30-second socket timeout + +# Enable gRPC keepalive to detect dead connections faster +os.environ.setdefault("GRPC_KEEPALIVE_TIME_MS", "30000") # 30 seconds +os.environ.setdefault("GRPC_KEEPALIVE_TIMEOUT_MS", "5000") # 5 seconds +os.environ.setdefault("GRPC_KEEPALIVE_PERMIT_WITHOUT_CALLS", "true") LOG_DIR = Path(__file__).parent.parent.parent.parent / "logs" LOG_FILE = "activity.log" @@ -18,7 +29,7 @@ SIMPLE_LOG_FORMAT = "%(asctime)s %(levelname)s %(title)s%(message)s" DEBUG_LOG_FORMAT = ( - "%(asctime)s %(levelname)s %(filename)s:%(lineno)d" " %(title)s%(message)s" + "%(asctime)s %(levelname)s %(filename)s:%(lineno)d %(title)s%(message)s" ) @@ -79,46 +90,45 @@ def configure_logging(force_cloud_logging: bool = False) -> None: Note: This function is typically called at the start of the application to set up the logging infrastructure. """ - config = LoggingConfig() - log_handlers: list[logging.Handler] = [] + # Console output handlers + stdout = logging.StreamHandler(stream=sys.stdout) + stdout.setLevel(config.level) + stdout.addFilter(BelowLevelFilter(logging.WARNING)) + if config.level == logging.DEBUG: + stdout.setFormatter(AGPTFormatter(DEBUG_LOG_FORMAT)) + else: + stdout.setFormatter(AGPTFormatter(SIMPLE_LOG_FORMAT)) + + stderr = logging.StreamHandler() + stderr.setLevel(logging.WARNING) + if config.level == logging.DEBUG: + stderr.setFormatter(AGPTFormatter(DEBUG_LOG_FORMAT)) + else: + stderr.setFormatter(AGPTFormatter(SIMPLE_LOG_FORMAT)) + + log_handlers += [stdout, stderr] + # Cloud logging setup if config.enable_cloud_logging or force_cloud_logging: import google.cloud.logging from google.cloud.logging.handlers import CloudLoggingHandler - from google.cloud.logging_v2.handlers.transports.sync import SyncTransport + from google.cloud.logging_v2.handlers.transports import ( + BackgroundThreadTransport, + ) client = google.cloud.logging.Client() + # Use BackgroundThreadTransport to prevent blocking the main thread + # and deadlocks when gRPC calls to Google Cloud Logging hang cloud_handler = CloudLoggingHandler( client, name="autogpt_logs", - transport=SyncTransport, + transport=BackgroundThreadTransport, ) cloud_handler.setLevel(config.level) - cloud_handler.setFormatter(StructuredLoggingFormatter()) log_handlers.append(cloud_handler) - print("Cloud logging enabled") - else: - # Console output handlers - stdout = logging.StreamHandler(stream=sys.stdout) - stdout.setLevel(config.level) - stdout.addFilter(BelowLevelFilter(logging.WARNING)) - if config.level == logging.DEBUG: - stdout.setFormatter(AGPTFormatter(DEBUG_LOG_FORMAT)) - else: - stdout.setFormatter(AGPTFormatter(SIMPLE_LOG_FORMAT)) - - stderr = logging.StreamHandler() - stderr.setLevel(logging.WARNING) - if config.level == logging.DEBUG: - stderr.setFormatter(AGPTFormatter(DEBUG_LOG_FORMAT)) - else: - stderr.setFormatter(AGPTFormatter(SIMPLE_LOG_FORMAT)) - - log_handlers += [stdout, stderr] - print("Console logging enabled") # File logging setup if config.enable_file_logging: @@ -156,7 +166,6 @@ def configure_logging(force_cloud_logging: bool = False) -> None: error_log_handler.setLevel(logging.ERROR) error_log_handler.setFormatter(AGPTFormatter(DEBUG_LOG_FORMAT, no_color=True)) log_handlers.append(error_log_handler) - print("File logging enabled") # Configure the root logger logging.basicConfig( diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/logging/formatters.py b/autogpt_platform/autogpt_libs/autogpt_libs/logging/formatters.py index cf27bc4667fd..28b998f2cecf 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/logging/formatters.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/logging/formatters.py @@ -1,7 +1,6 @@ import logging from colorama import Fore, Style -from google.cloud.logging_v2.handlers import CloudLoggingFilter, StructuredLogHandler from .utils import remove_color_codes @@ -80,16 +79,3 @@ def format(self, record: logging.LogRecord) -> str: return remove_color_codes(super().format(record)) else: return super().format(record) - - -class StructuredLoggingFormatter(StructuredLogHandler, logging.Formatter): - def __init__(self): - # Set up CloudLoggingFilter to add diagnostic info to the log records - self.cloud_logging_filter = CloudLoggingFilter() - - # Init StructuredLogHandler - super().__init__() - - def format(self, record: logging.LogRecord) -> str: - self.cloud_logging_filter.filter(record) - return super().format(record) diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/logging/utils.py b/autogpt_platform/autogpt_libs/autogpt_libs/logging/utils.py index 5c5c09221c32..0b92e2967d3a 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/logging/utils.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/logging/utils.py @@ -1,27 +1,5 @@ -import logging import re -from typing import Any - -from colorama import Fore def remove_color_codes(s: str) -> str: return re.sub(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])", "", s) - - -def fmt_kwargs(kwargs: dict) -> str: - return ", ".join(f"{n}={repr(v)}" for n, v in kwargs.items()) - - -def print_attribute( - title: str, value: Any, title_color: str = Fore.GREEN, value_color: str = "" -) -> None: - logger = logging.getLogger() - logger.info( - str(value), - extra={ - "title": f"{title.rstrip(':')}:", - "title_color": title_color, - "color": value_color, - }, - ) diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/supabase_integration_credentials_store/types.py b/autogpt_platform/autogpt_libs/autogpt_libs/supabase_integration_credentials_store/types.py new file mode 100644 index 000000000000..04c6fa2a7728 --- /dev/null +++ b/autogpt_platform/autogpt_libs/autogpt_libs/supabase_integration_credentials_store/types.py @@ -0,0 +1,76 @@ +from typing import Annotated, Any, Literal, Optional, TypedDict +from uuid import uuid4 + +from pydantic import BaseModel, Field, SecretStr, field_serializer + + +class _BaseCredentials(BaseModel): + id: str = Field(default_factory=lambda: str(uuid4())) + provider: str + title: Optional[str] + + @field_serializer("*") + def dump_secret_strings(value: Any, _info): + if isinstance(value, SecretStr): + return value.get_secret_value() + return value + + +class OAuth2Credentials(_BaseCredentials): + type: Literal["oauth2"] = "oauth2" + username: Optional[str] + """Username of the third-party service user that these credentials belong to""" + access_token: SecretStr + access_token_expires_at: Optional[int] + """Unix timestamp (seconds) indicating when the access token expires (if at all)""" + refresh_token: Optional[SecretStr] + refresh_token_expires_at: Optional[int] + """Unix timestamp (seconds) indicating when the refresh token expires (if at all)""" + scopes: list[str] + metadata: dict[str, Any] = Field(default_factory=dict) + + def bearer(self) -> str: + return f"Bearer {self.access_token.get_secret_value()}" + + +class APIKeyCredentials(_BaseCredentials): + type: Literal["api_key"] = "api_key" + api_key: SecretStr + expires_at: Optional[int] + """Unix timestamp (seconds) indicating when the API key expires (if at all)""" + + def bearer(self) -> str: + return f"Bearer {self.api_key.get_secret_value()}" + + +Credentials = Annotated[ + OAuth2Credentials | APIKeyCredentials, + Field(discriminator="type"), +] + + +CredentialsType = Literal["api_key", "oauth2"] + + +class OAuthState(BaseModel): + token: str + provider: str + expires_at: int + code_verifier: Optional[str] = None + scopes: list[str] + """Unix timestamp (seconds) indicating when this OAuth state expires""" + + +class UserMetadata(BaseModel): + integration_credentials: list[Credentials] = Field(default_factory=list) + integration_oauth_states: list[OAuthState] = Field(default_factory=list) + + +class UserMetadataRaw(TypedDict, total=False): + integration_credentials: list[dict] + integration_oauth_states: list[dict] + + +class UserIntegrations(BaseModel): + credentials: list[Credentials] = Field(default_factory=list) + oauth_states: list[OAuthState] = Field(default_factory=list) diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/utils/cache.py b/autogpt_platform/autogpt_libs/autogpt_libs/utils/cache.py index 5c0241c258cb..23328e46a340 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/utils/cache.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/utils/cache.py @@ -1,20 +1,266 @@ +import inspect +import logging import threading -from typing import Callable, ParamSpec, TypeVar +import time +from functools import wraps +from typing import ( + Awaitable, + Callable, + ParamSpec, + Protocol, + Tuple, + TypeVar, + cast, + overload, + runtime_checkable, +) P = ParamSpec("P") R = TypeVar("R") +logger = logging.getLogger(__name__) + +@overload +def thread_cached(func: Callable[P, Awaitable[R]]) -> Callable[P, Awaitable[R]]: + pass + + +@overload def thread_cached(func: Callable[P, R]) -> Callable[P, R]: + pass + + +def thread_cached( + func: Callable[P, R] | Callable[P, Awaitable[R]], +) -> Callable[P, R] | Callable[P, Awaitable[R]]: thread_local = threading.local() - def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: - cache = getattr(thread_local, "cache", None) - if cache is None: - cache = thread_local.cache = {} - key = (args, tuple(sorted(kwargs.items()))) - if key not in cache: - cache[key] = func(*args, **kwargs) - return cache[key] + def _clear(): + if hasattr(thread_local, "cache"): + del thread_local.cache + + if inspect.iscoroutinefunction(func): + + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + cache = getattr(thread_local, "cache", None) + if cache is None: + cache = thread_local.cache = {} + key = (args, tuple(sorted(kwargs.items()))) + if key not in cache: + cache[key] = await cast(Callable[P, Awaitable[R]], func)( + *args, **kwargs + ) + return cache[key] + + setattr(async_wrapper, "clear_cache", _clear) + return async_wrapper + + else: + + def sync_wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + cache = getattr(thread_local, "cache", None) + if cache is None: + cache = thread_local.cache = {} + key = (args, tuple(sorted(kwargs.items()))) + if key not in cache: + cache[key] = func(*args, **kwargs) + return cache[key] + + setattr(sync_wrapper, "clear_cache", _clear) + return sync_wrapper + + +def clear_thread_cache(func: Callable) -> None: + if clear := getattr(func, "clear_cache", None): + clear() + + +FuncT = TypeVar("FuncT") + + +R_co = TypeVar("R_co", covariant=True) + + +@runtime_checkable +class AsyncCachedFunction(Protocol[P, R_co]): + """Protocol for async functions with cache management methods.""" + + def cache_clear(self) -> None: + """Clear all cached entries.""" + return None + + def cache_info(self) -> dict[str, int | None]: + """Get cache statistics.""" + return {} + + async def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R_co: + """Call the cached function.""" + return None # type: ignore + + +def async_ttl_cache( + maxsize: int = 128, ttl_seconds: int | None = None +) -> Callable[[Callable[P, Awaitable[R]]], AsyncCachedFunction[P, R]]: + """ + TTL (Time To Live) cache decorator for async functions. + + Similar to functools.lru_cache but works with async functions and includes optional TTL. + + Args: + maxsize: Maximum number of cached entries + ttl_seconds: Time to live in seconds. If None, entries never expire (like lru_cache) + + Returns: + Decorator function + + Example: + # With TTL + @async_ttl_cache(maxsize=1000, ttl_seconds=300) + async def api_call(param: str) -> dict: + return {"result": param} + + # Without TTL (permanent cache like lru_cache) + @async_ttl_cache(maxsize=1000) + async def expensive_computation(param: str) -> dict: + return {"result": param} + """ + + def decorator( + async_func: Callable[P, Awaitable[R]], + ) -> AsyncCachedFunction[P, R]: + # Cache storage - use union type to handle both cases + cache_storage: dict[tuple, R | Tuple[R, float]] = {} + + @wraps(async_func) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: + # Create cache key from arguments + key = (args, tuple(sorted(kwargs.items()))) + current_time = time.time() + + # Check if we have a valid cached entry + if key in cache_storage: + if ttl_seconds is None: + # No TTL - return cached result directly + logger.debug( + f"Cache hit for {async_func.__name__} with key: {str(key)[:50]}" + ) + return cast(R, cache_storage[key]) + else: + # With TTL - check expiration + cached_data = cache_storage[key] + if isinstance(cached_data, tuple): + result, timestamp = cached_data + if current_time - timestamp < ttl_seconds: + logger.debug( + f"Cache hit for {async_func.__name__} with key: {str(key)[:50]}" + ) + return cast(R, result) + else: + # Expired entry + del cache_storage[key] + logger.debug( + f"Cache entry expired for {async_func.__name__}" + ) + + # Cache miss or expired - fetch fresh data + logger.debug( + f"Cache miss for {async_func.__name__} with key: {str(key)[:50]}" + ) + result = await async_func(*args, **kwargs) + + # Store in cache + if ttl_seconds is None: + cache_storage[key] = result + else: + cache_storage[key] = (result, current_time) + + # Simple cleanup when cache gets too large + if len(cache_storage) > maxsize: + # Remove oldest entries (simple FIFO cleanup) + cutoff = maxsize // 2 + oldest_keys = list(cache_storage.keys())[:-cutoff] if cutoff > 0 else [] + for old_key in oldest_keys: + cache_storage.pop(old_key, None) + logger.debug( + f"Cache cleanup: removed {len(oldest_keys)} entries for {async_func.__name__}" + ) + + return result + + # Add cache management methods (similar to functools.lru_cache) + def cache_clear() -> None: + cache_storage.clear() + + def cache_info() -> dict[str, int | None]: + return { + "size": len(cache_storage), + "maxsize": maxsize, + "ttl_seconds": ttl_seconds, + } + + # Attach methods to wrapper + setattr(wrapper, "cache_clear", cache_clear) + setattr(wrapper, "cache_info", cache_info) + + return cast(AsyncCachedFunction[P, R], wrapper) + + return decorator + + +@overload +def async_cache( + func: Callable[P, Awaitable[R]], +) -> AsyncCachedFunction[P, R]: + pass + + +@overload +def async_cache( + func: None = None, + *, + maxsize: int = 128, +) -> Callable[[Callable[P, Awaitable[R]]], AsyncCachedFunction[P, R]]: + pass + + +def async_cache( + func: Callable[P, Awaitable[R]] | None = None, + *, + maxsize: int = 128, +) -> ( + AsyncCachedFunction[P, R] + | Callable[[Callable[P, Awaitable[R]]], AsyncCachedFunction[P, R]] +): + """ + Process-level cache decorator for async functions (no TTL). + + Similar to functools.lru_cache but works with async functions. + This is a convenience wrapper around async_ttl_cache with ttl_seconds=None. + + Args: + func: The async function to cache (when used without parentheses) + maxsize: Maximum number of cached entries + + Returns: + Decorated function or decorator + + Example: + # Without parentheses (uses default maxsize=128) + @async_cache + async def get_data(param: str) -> dict: + return {"result": param} - return wrapper + # With parentheses and custom maxsize + @async_cache(maxsize=1000) + async def expensive_computation(param: str) -> dict: + # Expensive computation here + return {"result": param} + """ + if func is None: + # Called with parentheses @async_cache() or @async_cache(maxsize=...) + return async_ttl_cache(maxsize=maxsize, ttl_seconds=None) + else: + # Called without parentheses @async_cache + decorator = async_ttl_cache(maxsize=maxsize, ttl_seconds=None) + return decorator(func) diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/utils/cache_test.py b/autogpt_platform/autogpt_libs/autogpt_libs/utils/cache_test.py new file mode 100644 index 000000000000..e6ca3ecdfd2a --- /dev/null +++ b/autogpt_platform/autogpt_libs/autogpt_libs/utils/cache_test.py @@ -0,0 +1,705 @@ +"""Tests for the @thread_cached decorator. + +This module tests the thread-local caching functionality including: +- Basic caching for sync and async functions +- Thread isolation (each thread has its own cache) +- Cache clearing functionality +- Exception handling (exceptions are not cached) +- Argument handling (positional vs keyword arguments) +""" + +import asyncio +import threading +import time +from concurrent.futures import ThreadPoolExecutor +from unittest.mock import Mock + +import pytest + +from autogpt_libs.utils.cache import ( + async_cache, + async_ttl_cache, + clear_thread_cache, + thread_cached, +) + + +class TestThreadCached: + def test_sync_function_caching(self): + call_count = 0 + + @thread_cached + def expensive_function(x: int, y: int = 0) -> int: + nonlocal call_count + call_count += 1 + return x + y + + assert expensive_function(1, 2) == 3 + assert call_count == 1 + + assert expensive_function(1, 2) == 3 + assert call_count == 1 + + assert expensive_function(1, y=2) == 3 + assert call_count == 2 + + assert expensive_function(2, 3) == 5 + assert call_count == 3 + + assert expensive_function(1) == 1 + assert call_count == 4 + + @pytest.mark.asyncio + async def test_async_function_caching(self): + call_count = 0 + + @thread_cached + async def expensive_async_function(x: int, y: int = 0) -> int: + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.01) + return x + y + + assert await expensive_async_function(1, 2) == 3 + assert call_count == 1 + + assert await expensive_async_function(1, 2) == 3 + assert call_count == 1 + + assert await expensive_async_function(1, y=2) == 3 + assert call_count == 2 + + assert await expensive_async_function(2, 3) == 5 + assert call_count == 3 + + def test_thread_isolation(self): + call_count = 0 + results = {} + + @thread_cached + def thread_specific_function(x: int) -> str: + nonlocal call_count + call_count += 1 + return f"{threading.current_thread().name}-{x}" + + def worker(thread_id: int): + result1 = thread_specific_function(1) + result2 = thread_specific_function(1) + result3 = thread_specific_function(2) + results[thread_id] = (result1, result2, result3) + + with ThreadPoolExecutor(max_workers=3) as executor: + futures = [executor.submit(worker, i) for i in range(3)] + for future in futures: + future.result() + + assert call_count >= 2 + + for thread_id, (r1, r2, r3) in results.items(): + assert r1 == r2 + assert r1 != r3 + + @pytest.mark.asyncio + async def test_async_thread_isolation(self): + call_count = 0 + results = {} + + @thread_cached + async def async_thread_specific_function(x: int) -> str: + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.01) + return f"{threading.current_thread().name}-{x}" + + async def async_worker(worker_id: int): + result1 = await async_thread_specific_function(1) + result2 = await async_thread_specific_function(1) + result3 = await async_thread_specific_function(2) + results[worker_id] = (result1, result2, result3) + + tasks = [async_worker(i) for i in range(3)] + await asyncio.gather(*tasks) + + for worker_id, (r1, r2, r3) in results.items(): + assert r1 == r2 + assert r1 != r3 + + def test_clear_cache_sync(self): + call_count = 0 + + @thread_cached + def clearable_function(x: int) -> int: + nonlocal call_count + call_count += 1 + return x * 2 + + assert clearable_function(5) == 10 + assert call_count == 1 + + assert clearable_function(5) == 10 + assert call_count == 1 + + clear_thread_cache(clearable_function) + + assert clearable_function(5) == 10 + assert call_count == 2 + + @pytest.mark.asyncio + async def test_clear_cache_async(self): + call_count = 0 + + @thread_cached + async def clearable_async_function(x: int) -> int: + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.01) + return x * 2 + + assert await clearable_async_function(5) == 10 + assert call_count == 1 + + assert await clearable_async_function(5) == 10 + assert call_count == 1 + + clear_thread_cache(clearable_async_function) + + assert await clearable_async_function(5) == 10 + assert call_count == 2 + + def test_simple_arguments(self): + call_count = 0 + + @thread_cached + def simple_function(a: str, b: int, c: str = "default") -> str: + nonlocal call_count + call_count += 1 + return f"{a}-{b}-{c}" + + # First call with all positional args + result1 = simple_function("test", 42, "custom") + assert call_count == 1 + + # Same args, all positional - should hit cache + result2 = simple_function("test", 42, "custom") + assert call_count == 1 + assert result1 == result2 + + # Same values but last arg as keyword - creates different cache key + result3 = simple_function("test", 42, c="custom") + assert call_count == 2 + assert result1 == result3 # Same result, different cache entry + + # Different value - new cache entry + result4 = simple_function("test", 43, "custom") + assert call_count == 3 + assert result1 != result4 + + def test_positional_vs_keyword_args(self): + """Test that positional and keyword arguments create different cache entries.""" + call_count = 0 + + @thread_cached + def func(a: int, b: int = 10) -> str: + nonlocal call_count + call_count += 1 + return f"result-{a}-{b}" + + # All positional + result1 = func(1, 2) + assert call_count == 1 + assert result1 == "result-1-2" + + # Same values, but second arg as keyword + result2 = func(1, b=2) + assert call_count == 2 # Different cache key! + assert result2 == "result-1-2" # Same result + + # Verify both are cached separately + func(1, 2) # Uses first cache entry + assert call_count == 2 + + func(1, b=2) # Uses second cache entry + assert call_count == 2 + + def test_exception_handling(self): + call_count = 0 + + @thread_cached + def failing_function(x: int) -> int: + nonlocal call_count + call_count += 1 + if x < 0: + raise ValueError("Negative value") + return x * 2 + + assert failing_function(5) == 10 + assert call_count == 1 + + with pytest.raises(ValueError): + failing_function(-1) + assert call_count == 2 + + with pytest.raises(ValueError): + failing_function(-1) + assert call_count == 3 + + assert failing_function(5) == 10 + assert call_count == 3 + + @pytest.mark.asyncio + async def test_async_exception_handling(self): + call_count = 0 + + @thread_cached + async def async_failing_function(x: int) -> int: + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.01) + if x < 0: + raise ValueError("Negative value") + return x * 2 + + assert await async_failing_function(5) == 10 + assert call_count == 1 + + with pytest.raises(ValueError): + await async_failing_function(-1) + assert call_count == 2 + + with pytest.raises(ValueError): + await async_failing_function(-1) + assert call_count == 3 + + def test_sync_caching_performance(self): + @thread_cached + def slow_function(x: int) -> int: + print(f"slow_function called with x={x}") + time.sleep(0.1) + return x * 2 + + start = time.time() + result1 = slow_function(5) + first_call_time = time.time() - start + print(f"First call took {first_call_time:.4f} seconds") + + start = time.time() + result2 = slow_function(5) + second_call_time = time.time() - start + print(f"Second call took {second_call_time:.4f} seconds") + + assert result1 == result2 == 10 + assert first_call_time > 0.09 + assert second_call_time < 0.01 + + @pytest.mark.asyncio + async def test_async_caching_performance(self): + @thread_cached + async def slow_async_function(x: int) -> int: + print(f"slow_async_function called with x={x}") + await asyncio.sleep(0.1) + return x * 2 + + start = time.time() + result1 = await slow_async_function(5) + first_call_time = time.time() - start + print(f"First async call took {first_call_time:.4f} seconds") + + start = time.time() + result2 = await slow_async_function(5) + second_call_time = time.time() - start + print(f"Second async call took {second_call_time:.4f} seconds") + + assert result1 == result2 == 10 + assert first_call_time > 0.09 + assert second_call_time < 0.01 + + def test_with_mock_objects(self): + mock = Mock(return_value=42) + + @thread_cached + def function_using_mock(x: int) -> int: + return mock(x) + + assert function_using_mock(1) == 42 + assert mock.call_count == 1 + + assert function_using_mock(1) == 42 + assert mock.call_count == 1 + + assert function_using_mock(2) == 42 + assert mock.call_count == 2 + + +class TestAsyncTTLCache: + """Tests for the @async_ttl_cache decorator.""" + + @pytest.mark.asyncio + async def test_basic_caching(self): + """Test basic caching functionality.""" + call_count = 0 + + @async_ttl_cache(maxsize=10, ttl_seconds=60) + async def cached_function(x: int, y: int = 0) -> int: + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.01) # Simulate async work + return x + y + + # First call + result1 = await cached_function(1, 2) + assert result1 == 3 + assert call_count == 1 + + # Second call with same args - should use cache + result2 = await cached_function(1, 2) + assert result2 == 3 + assert call_count == 1 # No additional call + + # Different args - should call function again + result3 = await cached_function(2, 3) + assert result3 == 5 + assert call_count == 2 + + @pytest.mark.asyncio + async def test_ttl_expiration(self): + """Test that cache entries expire after TTL.""" + call_count = 0 + + @async_ttl_cache(maxsize=10, ttl_seconds=1) # Short TTL + async def short_lived_cache(x: int) -> int: + nonlocal call_count + call_count += 1 + return x * 2 + + # First call + result1 = await short_lived_cache(5) + assert result1 == 10 + assert call_count == 1 + + # Second call immediately - should use cache + result2 = await short_lived_cache(5) + assert result2 == 10 + assert call_count == 1 + + # Wait for TTL to expire + await asyncio.sleep(1.1) + + # Third call after expiration - should call function again + result3 = await short_lived_cache(5) + assert result3 == 10 + assert call_count == 2 + + @pytest.mark.asyncio + async def test_cache_info(self): + """Test cache info functionality.""" + + @async_ttl_cache(maxsize=5, ttl_seconds=300) + async def info_test_function(x: int) -> int: + return x * 3 + + # Check initial cache info + info = info_test_function.cache_info() + assert info["size"] == 0 + assert info["maxsize"] == 5 + assert info["ttl_seconds"] == 300 + + # Add an entry + await info_test_function(1) + info = info_test_function.cache_info() + assert info["size"] == 1 + + @pytest.mark.asyncio + async def test_cache_clear(self): + """Test cache clearing functionality.""" + call_count = 0 + + @async_ttl_cache(maxsize=10, ttl_seconds=60) + async def clearable_function(x: int) -> int: + nonlocal call_count + call_count += 1 + return x * 4 + + # First call + result1 = await clearable_function(2) + assert result1 == 8 + assert call_count == 1 + + # Second call - should use cache + result2 = await clearable_function(2) + assert result2 == 8 + assert call_count == 1 + + # Clear cache + clearable_function.cache_clear() + + # Third call after clear - should call function again + result3 = await clearable_function(2) + assert result3 == 8 + assert call_count == 2 + + @pytest.mark.asyncio + async def test_maxsize_cleanup(self): + """Test that cache cleans up when maxsize is exceeded.""" + call_count = 0 + + @async_ttl_cache(maxsize=3, ttl_seconds=60) + async def size_limited_function(x: int) -> int: + nonlocal call_count + call_count += 1 + return x**2 + + # Fill cache to maxsize + await size_limited_function(1) # call_count: 1 + await size_limited_function(2) # call_count: 2 + await size_limited_function(3) # call_count: 3 + + info = size_limited_function.cache_info() + assert info["size"] == 3 + + # Add one more entry - should trigger cleanup + await size_limited_function(4) # call_count: 4 + + # Cache size should be reduced (cleanup removes oldest entries) + info = size_limited_function.cache_info() + assert info["size"] is not None and info["size"] <= 3 # Should be cleaned up + + @pytest.mark.asyncio + async def test_argument_variations(self): + """Test caching with different argument patterns.""" + call_count = 0 + + @async_ttl_cache(maxsize=10, ttl_seconds=60) + async def arg_test_function(a: int, b: str = "default", *, c: int = 100) -> str: + nonlocal call_count + call_count += 1 + return f"{a}-{b}-{c}" + + # Different ways to call with same logical arguments + result1 = await arg_test_function(1, "test", c=200) + assert call_count == 1 + + # Same arguments, same order - should use cache + result2 = await arg_test_function(1, "test", c=200) + assert call_count == 1 + assert result1 == result2 + + # Different arguments - should call function + result3 = await arg_test_function(2, "test", c=200) + assert call_count == 2 + assert result1 != result3 + + @pytest.mark.asyncio + async def test_exception_handling(self): + """Test that exceptions are not cached.""" + call_count = 0 + + @async_ttl_cache(maxsize=10, ttl_seconds=60) + async def exception_function(x: int) -> int: + nonlocal call_count + call_count += 1 + if x < 0: + raise ValueError("Negative value not allowed") + return x * 2 + + # Successful call - should be cached + result1 = await exception_function(5) + assert result1 == 10 + assert call_count == 1 + + # Same successful call - should use cache + result2 = await exception_function(5) + assert result2 == 10 + assert call_count == 1 + + # Exception call - should not be cached + with pytest.raises(ValueError): + await exception_function(-1) + assert call_count == 2 + + # Same exception call - should call again (not cached) + with pytest.raises(ValueError): + await exception_function(-1) + assert call_count == 3 + + @pytest.mark.asyncio + async def test_concurrent_calls(self): + """Test caching behavior with concurrent calls.""" + call_count = 0 + + @async_ttl_cache(maxsize=10, ttl_seconds=60) + async def concurrent_function(x: int) -> int: + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.05) # Simulate work + return x * x + + # Launch concurrent calls with same arguments + tasks = [concurrent_function(3) for _ in range(5)] + results = await asyncio.gather(*tasks) + + # All results should be the same + assert all(result == 9 for result in results) + + # Note: Due to race conditions, call_count might be up to 5 for concurrent calls + # This tests that the cache doesn't break under concurrent access + assert 1 <= call_count <= 5 + + +class TestAsyncCache: + """Tests for the @async_cache decorator (no TTL).""" + + @pytest.mark.asyncio + async def test_basic_caching_no_ttl(self): + """Test basic caching functionality without TTL.""" + call_count = 0 + + @async_cache(maxsize=10) + async def cached_function(x: int, y: int = 0) -> int: + nonlocal call_count + call_count += 1 + await asyncio.sleep(0.01) # Simulate async work + return x + y + + # First call + result1 = await cached_function(1, 2) + assert result1 == 3 + assert call_count == 1 + + # Second call with same args - should use cache + result2 = await cached_function(1, 2) + assert result2 == 3 + assert call_count == 1 # No additional call + + # Third call after some time - should still use cache (no TTL) + await asyncio.sleep(0.05) + result3 = await cached_function(1, 2) + assert result3 == 3 + assert call_count == 1 # Still no additional call + + # Different args - should call function again + result4 = await cached_function(2, 3) + assert result4 == 5 + assert call_count == 2 + + @pytest.mark.asyncio + async def test_no_ttl_vs_ttl_behavior(self): + """Test the difference between TTL and no-TTL caching.""" + ttl_call_count = 0 + no_ttl_call_count = 0 + + @async_ttl_cache(maxsize=10, ttl_seconds=1) # Short TTL + async def ttl_function(x: int) -> int: + nonlocal ttl_call_count + ttl_call_count += 1 + return x * 2 + + @async_cache(maxsize=10) # No TTL + async def no_ttl_function(x: int) -> int: + nonlocal no_ttl_call_count + no_ttl_call_count += 1 + return x * 2 + + # First calls + await ttl_function(5) + await no_ttl_function(5) + assert ttl_call_count == 1 + assert no_ttl_call_count == 1 + + # Wait for TTL to expire + await asyncio.sleep(1.1) + + # Second calls after TTL expiry + await ttl_function(5) # Should call function again (TTL expired) + await no_ttl_function(5) # Should use cache (no TTL) + assert ttl_call_count == 2 # TTL function called again + assert no_ttl_call_count == 1 # No-TTL function still cached + + @pytest.mark.asyncio + async def test_async_cache_info(self): + """Test cache info for no-TTL cache.""" + + @async_cache(maxsize=5) + async def info_test_function(x: int) -> int: + return x * 3 + + # Check initial cache info + info = info_test_function.cache_info() + assert info["size"] == 0 + assert info["maxsize"] == 5 + assert info["ttl_seconds"] is None # No TTL + + # Add an entry + await info_test_function(1) + info = info_test_function.cache_info() + assert info["size"] == 1 + + +class TestTTLOptional: + """Tests for optional TTL functionality.""" + + @pytest.mark.asyncio + async def test_ttl_none_behavior(self): + """Test that ttl_seconds=None works like no TTL.""" + call_count = 0 + + @async_ttl_cache(maxsize=10, ttl_seconds=None) + async def no_ttl_via_none(x: int) -> int: + nonlocal call_count + call_count += 1 + return x**2 + + # First call + result1 = await no_ttl_via_none(3) + assert result1 == 9 + assert call_count == 1 + + # Wait (would expire if there was TTL) + await asyncio.sleep(0.1) + + # Second call - should still use cache + result2 = await no_ttl_via_none(3) + assert result2 == 9 + assert call_count == 1 # No additional call + + # Check cache info + info = no_ttl_via_none.cache_info() + assert info["ttl_seconds"] is None + + @pytest.mark.asyncio + async def test_cache_options_comparison(self): + """Test different cache options work as expected.""" + ttl_calls = 0 + no_ttl_calls = 0 + + @async_ttl_cache(maxsize=10, ttl_seconds=1) # With TTL + async def ttl_function(x: int) -> int: + nonlocal ttl_calls + ttl_calls += 1 + return x * 10 + + @async_cache(maxsize=10) # Process-level cache (no TTL) + async def process_function(x: int) -> int: + nonlocal no_ttl_calls + no_ttl_calls += 1 + return x * 10 + + # Both should cache initially + await ttl_function(3) + await process_function(3) + assert ttl_calls == 1 + assert no_ttl_calls == 1 + + # Immediate second calls - both should use cache + await ttl_function(3) + await process_function(3) + assert ttl_calls == 1 + assert no_ttl_calls == 1 + + # Wait for TTL to expire + await asyncio.sleep(1.1) + + # After TTL expiry + await ttl_function(3) # Should call function again + await process_function(3) # Should still use cache + assert ttl_calls == 2 # TTL cache expired, called again + assert no_ttl_calls == 1 # Process cache never expires diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/utils/synchronize.py b/autogpt_platform/autogpt_libs/autogpt_libs/utils/synchronize.py index bdd0aa79e634..348ae4d78db3 100644 --- a/autogpt_platform/autogpt_libs/autogpt_libs/utils/synchronize.py +++ b/autogpt_platform/autogpt_libs/autogpt_libs/utils/synchronize.py @@ -1,15 +1,15 @@ -from contextlib import contextmanager -from threading import Lock +import asyncio +from contextlib import asynccontextmanager from typing import TYPE_CHECKING, Any from expiringdict import ExpiringDict if TYPE_CHECKING: - from redis import Redis - from redis.lock import Lock as RedisLock + from redis.asyncio import Redis as AsyncRedis + from redis.asyncio.lock import Lock as AsyncRedisLock -class RedisKeyedMutex: +class AsyncRedisKeyedMutex: """ This class provides a mutex that can be locked and unlocked by a specific key, using Redis as a distributed locking provider. @@ -17,40 +17,45 @@ class RedisKeyedMutex: in case the key is not unlocked for a specified duration, to prevent memory leaks. """ - def __init__(self, redis: "Redis", timeout: int | None = 60): + def __init__(self, redis: "AsyncRedis", timeout: int | None = 60): self.redis = redis self.timeout = timeout - self.locks: dict[Any, "RedisLock"] = ExpiringDict( + self.locks: dict[Any, "AsyncRedisLock"] = ExpiringDict( max_len=6000, max_age_seconds=self.timeout ) - self.locks_lock = Lock() + self.locks_lock = asyncio.Lock() - @contextmanager - def locked(self, key: Any): - lock = self.acquire(key) + @asynccontextmanager + async def locked(self, key: Any): + lock = await self.acquire(key) try: yield finally: - lock.release() + if (await lock.locked()) and (await lock.owned()): + await lock.release() - def acquire(self, key: Any) -> "RedisLock": + async def acquire(self, key: Any) -> "AsyncRedisLock": """Acquires and returns a lock with the given key""" - with self.locks_lock: + async with self.locks_lock: if key not in self.locks: self.locks[key] = self.redis.lock( str(key), self.timeout, thread_local=False ) lock = self.locks[key] - lock.acquire() + await lock.acquire() return lock - def release(self, key: Any): - if lock := self.locks.get(key): - lock.release() + async def release(self, key: Any): + if ( + (lock := self.locks.get(key)) + and (await lock.locked()) + and (await lock.owned()) + ): + await lock.release() - def release_all_locks(self): + async def release_all_locks(self): """Call this on process termination to ensure all locks are released""" - self.locks_lock.acquire(blocking=False) - for lock in self.locks.values(): - if lock.locked() and lock.owned(): - lock.release() + async with self.locks_lock: + for lock in self.locks.values(): + if (await lock.locked()) and (await lock.owned()): + await lock.release() diff --git a/autogpt_platform/autogpt_libs/poetry.lock b/autogpt_platform/autogpt_libs/poetry.lock index 1e830c2972bf..8f160b23df4c 100644 --- a/autogpt_platform/autogpt_libs/poetry.lock +++ b/autogpt_platform/autogpt_libs/poetry.lock @@ -1,141 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. - -[[package]] -name = "aiohappyeyeballs" -version = "2.4.0" -description = "Happy Eyeballs for asyncio" -optional = false -python-versions = ">=3.8" -files = [ - {file = "aiohappyeyeballs-2.4.0-py3-none-any.whl", hash = "sha256:7ce92076e249169a13c2f49320d1967425eaf1f407522d707d59cac7628d62bd"}, - {file = "aiohappyeyeballs-2.4.0.tar.gz", hash = "sha256:55a1714f084e63d49639800f95716da97a1f173d46a16dfcfda0016abb93b6b2"}, -] - -[[package]] -name = "aiohttp" -version = "3.10.5" -description = "Async http client/server framework (asyncio)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:18a01eba2574fb9edd5f6e5fb25f66e6ce061da5dab5db75e13fe1558142e0a3"}, - {file = "aiohttp-3.10.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:94fac7c6e77ccb1ca91e9eb4cb0ac0270b9fb9b289738654120ba8cebb1189c6"}, - {file = "aiohttp-3.10.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2f1f1c75c395991ce9c94d3e4aa96e5c59c8356a15b1c9231e783865e2772699"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f7acae3cf1a2a2361ec4c8e787eaaa86a94171d2417aae53c0cca6ca3118ff6"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94c4381ffba9cc508b37d2e536b418d5ea9cfdc2848b9a7fea6aebad4ec6aac1"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c31ad0c0c507894e3eaa843415841995bf8de4d6b2d24c6e33099f4bc9fc0d4f"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0912b8a8fadeb32ff67a3ed44249448c20148397c1ed905d5dac185b4ca547bb"}, - {file = "aiohttp-3.10.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d93400c18596b7dc4794d48a63fb361b01a0d8eb39f28800dc900c8fbdaca91"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3c5e0d764a5c9aa5a62d99728c56d455310bcc288a79cab10157b3af426f"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:d742c36ed44f2798c8d3f4bc511f479b9ceef2b93f348671184139e7d708042c"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:814375093edae5f1cb31e3407997cf3eacefb9010f96df10d64829362ae2df69"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8224f98be68a84b19f48e0bdc14224b5a71339aff3a27df69989fa47d01296f3"}, - {file = "aiohttp-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d9a487ef090aea982d748b1b0d74fe7c3950b109df967630a20584f9a99c0683"}, - {file = "aiohttp-3.10.5-cp310-cp310-win32.whl", hash = "sha256:d9ef084e3dc690ad50137cc05831c52b6ca428096e6deb3c43e95827f531d5ef"}, - {file = "aiohttp-3.10.5-cp310-cp310-win_amd64.whl", hash = "sha256:66bf9234e08fe561dccd62083bf67400bdbf1c67ba9efdc3dac03650e97c6088"}, - {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8c6a4e5e40156d72a40241a25cc226051c0a8d816610097a8e8f517aeacd59a2"}, - {file = "aiohttp-3.10.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c634a3207a5445be65536d38c13791904fda0748b9eabf908d3fe86a52941cf"}, - {file = "aiohttp-3.10.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4aff049b5e629ef9b3e9e617fa6e2dfeda1bf87e01bcfecaf3949af9e210105e"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1942244f00baaacaa8155eca94dbd9e8cc7017deb69b75ef67c78e89fdad3c77"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04a1f2a65ad2f93aa20f9ff9f1b672bf912413e5547f60749fa2ef8a644e061"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7f2bfc0032a00405d4af2ba27f3c429e851d04fad1e5ceee4080a1c570476697"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:424ae21498790e12eb759040bbb504e5e280cab64693d14775c54269fd1d2bb7"}, - {file = "aiohttp-3.10.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:975218eee0e6d24eb336d0328c768ebc5d617609affaca5dbbd6dd1984f16ed0"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4120d7fefa1e2d8fb6f650b11489710091788de554e2b6f8347c7a20ceb003f5"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b90078989ef3fc45cf9221d3859acd1108af7560c52397ff4ace8ad7052a132e"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ba5a8b74c2a8af7d862399cdedce1533642fa727def0b8c3e3e02fcb52dca1b1"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:02594361128f780eecc2a29939d9dfc870e17b45178a867bf61a11b2a4367277"}, - {file = "aiohttp-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8fb4fc029e135859f533025bc82047334e24b0d489e75513144f25408ecaf058"}, - {file = "aiohttp-3.10.5-cp311-cp311-win32.whl", hash = "sha256:e1ca1ef5ba129718a8fc827b0867f6aa4e893c56eb00003b7367f8a733a9b072"}, - {file = "aiohttp-3.10.5-cp311-cp311-win_amd64.whl", hash = "sha256:349ef8a73a7c5665cca65c88ab24abe75447e28aa3bc4c93ea5093474dfdf0ff"}, - {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:305be5ff2081fa1d283a76113b8df7a14c10d75602a38d9f012935df20731487"}, - {file = "aiohttp-3.10.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3a1c32a19ee6bbde02f1cb189e13a71b321256cc1d431196a9f824050b160d5a"}, - {file = "aiohttp-3.10.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:61645818edd40cc6f455b851277a21bf420ce347baa0b86eaa41d51ef58ba23d"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c225286f2b13bab5987425558baa5cbdb2bc925b2998038fa028245ef421e75"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ba01ebc6175e1e6b7275c907a3a36be48a2d487549b656aa90c8a910d9f3178"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8eaf44ccbc4e35762683078b72bf293f476561d8b68ec8a64f98cf32811c323e"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1c43eb1ab7cbf411b8e387dc169acb31f0ca0d8c09ba63f9eac67829585b44f"}, - {file = "aiohttp-3.10.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de7a5299827253023c55ea549444e058c0eb496931fa05d693b95140a947cb73"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4790f0e15f00058f7599dab2b206d3049d7ac464dc2e5eae0e93fa18aee9e7bf"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:44b324a6b8376a23e6ba25d368726ee3bc281e6ab306db80b5819999c737d820"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0d277cfb304118079e7044aad0b76685d30ecb86f83a0711fc5fb257ffe832ca"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:54d9ddea424cd19d3ff6128601a4a4d23d54a421f9b4c0fff740505813739a91"}, - {file = "aiohttp-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4f1c9866ccf48a6df2b06823e6ae80573529f2af3a0992ec4fe75b1a510df8a6"}, - {file = "aiohttp-3.10.5-cp312-cp312-win32.whl", hash = "sha256:dc4826823121783dccc0871e3f405417ac116055bf184ac04c36f98b75aacd12"}, - {file = "aiohttp-3.10.5-cp312-cp312-win_amd64.whl", hash = "sha256:22c0a23a3b3138a6bf76fc553789cb1a703836da86b0f306b6f0dc1617398abc"}, - {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7f6b639c36734eaa80a6c152a238242bedcee9b953f23bb887e9102976343092"}, - {file = "aiohttp-3.10.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29930bc2921cef955ba39a3ff87d2c4398a0394ae217f41cb02d5c26c8b1b77"}, - {file = "aiohttp-3.10.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f489a2c9e6455d87eabf907ac0b7d230a9786be43fbe884ad184ddf9e9c1e385"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:123dd5b16b75b2962d0fff566effb7a065e33cd4538c1692fb31c3bda2bfb972"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b98e698dc34966e5976e10bbca6d26d6724e6bdea853c7c10162a3235aba6e16"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c3b9162bab7e42f21243effc822652dc5bb5e8ff42a4eb62fe7782bcbcdfacf6"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1923a5c44061bffd5eebeef58cecf68096e35003907d8201a4d0d6f6e387ccaa"}, - {file = "aiohttp-3.10.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d55f011da0a843c3d3df2c2cf4e537b8070a419f891c930245f05d329c4b0689"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:afe16a84498441d05e9189a15900640a2d2b5e76cf4efe8cbb088ab4f112ee57"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f8112fb501b1e0567a1251a2fd0747baae60a4ab325a871e975b7bb67e59221f"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1e72589da4c90337837fdfe2026ae1952c0f4a6e793adbbfbdd40efed7c63599"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:4d46c7b4173415d8e583045fbc4daa48b40e31b19ce595b8d92cf639396c15d5"}, - {file = "aiohttp-3.10.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:33e6bc4bab477c772a541f76cd91e11ccb6d2efa2b8d7d7883591dfb523e5987"}, - {file = "aiohttp-3.10.5-cp313-cp313-win32.whl", hash = "sha256:c58c6837a2c2a7cf3133983e64173aec11f9c2cd8e87ec2fdc16ce727bcf1a04"}, - {file = "aiohttp-3.10.5-cp313-cp313-win_amd64.whl", hash = "sha256:38172a70005252b6893088c0f5e8a47d173df7cc2b2bd88650957eb84fcf5022"}, - {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:f6f18898ace4bcd2d41a122916475344a87f1dfdec626ecde9ee802a711bc569"}, - {file = "aiohttp-3.10.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:5ede29d91a40ba22ac1b922ef510aab871652f6c88ef60b9dcdf773c6d32ad7a"}, - {file = "aiohttp-3.10.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:673f988370f5954df96cc31fd99c7312a3af0a97f09e407399f61583f30da9bc"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58718e181c56a3c02d25b09d4115eb02aafe1a732ce5714ab70326d9776457c3"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4b38b1570242fbab8d86a84128fb5b5234a2f70c2e32f3070143a6d94bc854cf"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:074d1bff0163e107e97bd48cad9f928fa5a3eb4b9d33366137ffce08a63e37fe"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd31f176429cecbc1ba499d4aba31aaccfea488f418d60376b911269d3b883c5"}, - {file = "aiohttp-3.10.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7384d0b87d4635ec38db9263e6a3f1eb609e2e06087f0aa7f63b76833737b471"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8989f46f3d7ef79585e98fa991e6ded55d2f48ae56d2c9fa5e491a6e4effb589"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:c83f7a107abb89a227d6c454c613e7606c12a42b9a4ca9c5d7dad25d47c776ae"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cde98f323d6bf161041e7627a5fd763f9fd829bcfcd089804a5fdce7bb6e1b7d"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:676f94c5480d8eefd97c0c7e3953315e4d8c2b71f3b49539beb2aa676c58272f"}, - {file = "aiohttp-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:2d21ac12dc943c68135ff858c3a989f2194a709e6e10b4c8977d7fcd67dfd511"}, - {file = "aiohttp-3.10.5-cp38-cp38-win32.whl", hash = "sha256:17e997105bd1a260850272bfb50e2a328e029c941c2708170d9d978d5a30ad9a"}, - {file = "aiohttp-3.10.5-cp38-cp38-win_amd64.whl", hash = "sha256:1c19de68896747a2aa6257ae4cf6ef59d73917a36a35ee9d0a6f48cff0f94db8"}, - {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7e2fe37ac654032db1f3499fe56e77190282534810e2a8e833141a021faaab0e"}, - {file = "aiohttp-3.10.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5bf3ead3cb66ab990ee2561373b009db5bc0e857549b6c9ba84b20bc462e172"}, - {file = "aiohttp-3.10.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1b2c16a919d936ca87a3c5f0e43af12a89a3ce7ccbce59a2d6784caba945b68b"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad146dae5977c4dd435eb31373b3fe9b0b1bf26858c6fc452bf6af394067e10b"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c5c6fa16412b35999320f5c9690c0f554392dc222c04e559217e0f9ae244b92"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95c4dc6f61d610bc0ee1edc6f29d993f10febfe5b76bb470b486d90bbece6b22"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da452c2c322e9ce0cfef392e469a26d63d42860f829026a63374fde6b5c5876f"}, - {file = "aiohttp-3.10.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:898715cf566ec2869d5cb4d5fb4be408964704c46c96b4be267442d265390f32"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:391cc3a9c1527e424c6865e087897e766a917f15dddb360174a70467572ac6ce"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:380f926b51b92d02a34119d072f178d80bbda334d1a7e10fa22d467a66e494db"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce91db90dbf37bb6fa0997f26574107e1b9d5ff939315247b7e615baa8ec313b"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9093a81e18c45227eebe4c16124ebf3e0d893830c6aca7cc310bfca8fe59d857"}, - {file = "aiohttp-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:ee40b40aa753d844162dcc80d0fe256b87cba48ca0054f64e68000453caead11"}, - {file = "aiohttp-3.10.5-cp39-cp39-win32.whl", hash = "sha256:03f2645adbe17f274444953bdea69f8327e9d278d961d85657cb0d06864814c1"}, - {file = "aiohttp-3.10.5-cp39-cp39-win_amd64.whl", hash = "sha256:d17920f18e6ee090bdd3d0bfffd769d9f2cb4c8ffde3eb203777a3895c128862"}, - {file = "aiohttp-3.10.5.tar.gz", hash = "sha256:f071854b47d39591ce9a17981c46790acb30518e2f83dfca8db2dfa091178691"}, -] - -[package.dependencies] -aiohappyeyeballs = ">=2.3.0" -aiosignal = ">=1.1.2" -async-timeout = {version = ">=4.0,<5.0", markers = "python_version < \"3.11\""} -attrs = ">=17.3.0" -frozenlist = ">=1.1.1" -multidict = ">=4.5,<7.0" -yarl = ">=1.0,<2.0" - -[package.extras] -speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] - -[[package]] -name = "aiosignal" -version = "1.3.1" -description = "aiosignal: a list of registered asynchronous callbacks" -optional = false -python-versions = ">=3.7" -files = [ - {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, - {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, -] - -[package.dependencies] -frozenlist = ">=1.1.0" +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "annotated-types" @@ -143,6 +6,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -150,211 +14,213 @@ files = [ [[package]] name = "anyio" -version = "4.4.0" +version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, - {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, + {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, + {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, ] [package.dependencies] exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} idna = ">=2.8" sniffio = ">=1.1" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} +typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] -trio = ["trio (>=0.23)"] +doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] +trio = ["trio (>=0.26.1)"] [[package]] name = "async-timeout" -version = "4.0.3" +version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["main"] +markers = "python_full_version < \"3.11.3\"" files = [ - {file = "async-timeout-4.0.3.tar.gz", hash = "sha256:4640d96be84d82d02ed59ea2b7105a0f7b33abe8703703cd0ab0bf87c427522f"}, - {file = "async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028"}, + {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, + {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, ] [[package]] -name = "attrs" -version = "24.2.0" -description = "Classes Without Boilerplate" +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." optional = false -python-versions = ">=3.7" +python-versions = "<3.11,>=3.8" +groups = ["main"] +markers = "python_version < \"3.11\"" files = [ - {file = "attrs-24.2.0-py3-none-any.whl", hash = "sha256:81921eb96de3191c8258c199618104dd27ac608d9366f5e35d011eae1867ede2"}, - {file = "attrs-24.2.0.tar.gz", hash = "sha256:5cfb1b9148b5b086569baec03f20d7b6bf3bcacc9a42bebf87ffaaca362f6346"}, + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, ] -[package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] - [[package]] name = "cachetools" -version = "5.5.0" +version = "5.5.2" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, - {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, + {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, + {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, ] [[package]] name = "certifi" -version = "2024.8.30" +version = "2025.7.14" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "certifi-2024.8.30-py3-none-any.whl", hash = "sha256:922820b53db7a7257ffbda3f597266d435245903d80737e34f8a45ff3e3230d8"}, - {file = "certifi-2024.8.30.tar.gz", hash = "sha256:bec941d2aa8195e248a60b31ff9f0558284cf01a52591ceda73ea9afffd69fd9"}, + {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, + {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, ] [[package]] name = "charset-normalizer" -version = "3.3.2" +version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, - {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, - {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, - {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, - {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, - {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, - {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, - {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, +] + +[[package]] +name = "click" +version = "8.2.1" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, ] +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] -[[package]] -name = "deprecated" -version = "1.2.14" -description = "Python @deprecated decorator to deprecate old python classes, functions or methods." -optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" -files = [ - {file = "Deprecated-1.2.14-py2.py3-none-any.whl", hash = "sha256:6fac8b097794a90302bdbb17b9b815e732d3c4720583ff1b198499d78470466c"}, - {file = "Deprecated-1.2.14.tar.gz", hash = "sha256:e5323eb936458dccc2582dc6f9c322c852a775a27065ff2b0c4970b9d53d01b3"}, -] - -[package.dependencies] -wrapt = ">=1.10,<2" - -[package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "sphinx (<2)", "tox"] - [[package]] name = "deprecation" version = "2.1.0" description = "A library to handle automated deprecations" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, @@ -365,15 +231,20 @@ packaging = "*" [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["main"] +markers = "python_version < \"3.11\"" files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + [package.extras] test = ["pytest (>=6)"] @@ -383,6 +254,7 @@ version = "1.2.2" description = "Dictionary with auto-expiring values for caching purposes" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "expiringdict-1.2.2-py3-none-any.whl", hash = "sha256:09a5d20bc361163e6432a874edd3179676e935eb81b925eccef48d409a8a45e8"}, {file = "expiringdict-1.2.2.tar.gz", hash = "sha256:300fb92a7e98f15b05cf9a856c1415b3bc4f2e132be07daa326da6414c23ee09"}, @@ -392,131 +264,73 @@ files = [ tests = ["coverage", "coveralls", "dill", "mock", "nose"] [[package]] -name = "frozenlist" -version = "1.4.1" -description = "A list-like structure which implements collections.abc.MutableSequence" +name = "fastapi" +version = "0.116.1" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f9aa1878d1083b276b0196f2dfbe00c9b7e752475ed3b682025ff20c1c1f51ac"}, - {file = "frozenlist-1.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29acab3f66f0f24674b7dc4736477bcd4bc3ad4b896f5f45379a67bce8b96868"}, - {file = "frozenlist-1.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:74fb4bee6880b529a0c6560885fce4dc95936920f9f20f53d99a213f7bf66776"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:590344787a90ae57d62511dd7c736ed56b428f04cd8c161fcc5e7232c130c69a"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:068b63f23b17df8569b7fdca5517edef76171cf3897eb68beb01341131fbd2ad"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c849d495bf5154cd8da18a9eb15db127d4dba2968d88831aff6f0331ea9bd4c"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9750cc7fe1ae3b1611bb8cfc3f9ec11d532244235d75901fb6b8e42ce9229dfe"}, - {file = "frozenlist-1.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a9b2de4cf0cdd5bd2dee4c4f63a653c61d2408055ab77b151c1957f221cabf2a"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:0633c8d5337cb5c77acbccc6357ac49a1770b8c487e5b3505c57b949b4b82e98"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:27657df69e8801be6c3638054e202a135c7f299267f1a55ed3a598934f6c0d75"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:f9a3ea26252bd92f570600098783d1371354d89d5f6b7dfd87359d669f2109b5"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:4f57dab5fe3407b6c0c1cc907ac98e8a189f9e418f3b6e54d65a718aaafe3950"}, - {file = "frozenlist-1.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:e02a0e11cf6597299b9f3bbd3f93d79217cb90cfd1411aec33848b13f5c656cc"}, - {file = "frozenlist-1.4.1-cp310-cp310-win32.whl", hash = "sha256:a828c57f00f729620a442881cc60e57cfcec6842ba38e1b19fd3e47ac0ff8dc1"}, - {file = "frozenlist-1.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:f56e2333dda1fe0f909e7cc59f021eba0d2307bc6f012a1ccf2beca6ba362439"}, - {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a0cb6f11204443f27a1628b0e460f37fb30f624be6051d490fa7d7e26d4af3d0"}, - {file = "frozenlist-1.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b46c8ae3a8f1f41a0d2ef350c0b6e65822d80772fe46b653ab6b6274f61d4a49"}, - {file = "frozenlist-1.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fde5bd59ab5357e3853313127f4d3565fc7dad314a74d7b5d43c22c6a5ed2ced"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:722e1124aec435320ae01ee3ac7bec11a5d47f25d0ed6328f2273d287bc3abb0"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2471c201b70d58a0f0c1f91261542a03d9a5e088ed3dc6c160d614c01649c106"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c757a9dd70d72b076d6f68efdbb9bc943665ae954dad2801b874c8c69e185068"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f146e0911cb2f1da549fc58fc7bcd2b836a44b79ef871980d605ec392ff6b0d2"}, - {file = "frozenlist-1.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f9c515e7914626b2a2e1e311794b4c35720a0be87af52b79ff8e1429fc25f19"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c302220494f5c1ebeb0912ea782bcd5e2f8308037b3c7553fad0e48ebad6ad82"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:442acde1e068288a4ba7acfe05f5f343e19fac87bfc96d89eb886b0363e977ec"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:1b280e6507ea8a4fa0c0a7150b4e526a8d113989e28eaaef946cc77ffd7efc0a"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:fe1a06da377e3a1062ae5fe0926e12b84eceb8a50b350ddca72dc85015873f74"}, - {file = "frozenlist-1.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:db9e724bebd621d9beca794f2a4ff1d26eed5965b004a97f1f1685a173b869c2"}, - {file = "frozenlist-1.4.1-cp311-cp311-win32.whl", hash = "sha256:e774d53b1a477a67838a904131c4b0eef6b3d8a651f8b138b04f748fccfefe17"}, - {file = "frozenlist-1.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fb3c2db03683b5767dedb5769b8a40ebb47d6f7f45b1b3e3b4b51ec8ad9d9825"}, - {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:1979bc0aeb89b33b588c51c54ab0161791149f2461ea7c7c946d95d5f93b56ae"}, - {file = "frozenlist-1.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc7b01b3754ea68a62bd77ce6020afaffb44a590c2289089289363472d13aedb"}, - {file = "frozenlist-1.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c9c92be9fd329ac801cc420e08452b70e7aeab94ea4233a4804f0915c14eba9b"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c3894db91f5a489fc8fa6a9991820f368f0b3cbdb9cd8849547ccfab3392d86"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba60bb19387e13597fb059f32cd4d59445d7b18b69a745b8f8e5db0346f33480"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8aefbba5f69d42246543407ed2461db31006b0f76c4e32dfd6f42215a2c41d09"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:780d3a35680ced9ce682fbcf4cb9c2bad3136eeff760ab33707b71db84664e3a"}, - {file = "frozenlist-1.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9acbb16f06fe7f52f441bb6f413ebae6c37baa6ef9edd49cdd567216da8600cd"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:23b701e65c7b36e4bf15546a89279bd4d8675faabc287d06bbcfac7d3c33e1e6"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:3e0153a805a98f5ada7e09826255ba99fb4f7524bb81bf6b47fb702666484ae1"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:dd9b1baec094d91bf36ec729445f7769d0d0cf6b64d04d86e45baf89e2b9059b"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:1a4471094e146b6790f61b98616ab8e44f72661879cc63fa1049d13ef711e71e"}, - {file = "frozenlist-1.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5667ed53d68d91920defdf4035d1cdaa3c3121dc0b113255124bcfada1cfa1b8"}, - {file = "frozenlist-1.4.1-cp312-cp312-win32.whl", hash = "sha256:beee944ae828747fd7cb216a70f120767fc9f4f00bacae8543c14a6831673f89"}, - {file = "frozenlist-1.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:64536573d0a2cb6e625cf309984e2d873979709f2cf22839bf2d61790b448ad5"}, - {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:20b51fa3f588ff2fe658663db52a41a4f7aa6c04f6201449c6c7c476bd255c0d"}, - {file = "frozenlist-1.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:410478a0c562d1a5bcc2f7ea448359fcb050ed48b3c6f6f4f18c313a9bdb1826"}, - {file = "frozenlist-1.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:c6321c9efe29975232da3bd0af0ad216800a47e93d763ce64f291917a381b8eb"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48f6a4533887e189dae092f1cf981f2e3885175f7a0f33c91fb5b7b682b6bab6"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6eb73fa5426ea69ee0e012fb59cdc76a15b1283d6e32e4f8dc4482ec67d1194d"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbeb989b5cc29e8daf7f976b421c220f1b8c731cbf22b9130d8815418ea45887"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32453c1de775c889eb4e22f1197fe3bdfe457d16476ea407472b9442e6295f7a"}, - {file = "frozenlist-1.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:693945278a31f2086d9bf3df0fe8254bbeaef1fe71e1351c3bd730aa7d31c41b"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:1d0ce09d36d53bbbe566fe296965b23b961764c0bcf3ce2fa45f463745c04701"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:3a670dc61eb0d0eb7080890c13de3066790f9049b47b0de04007090807c776b0"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:dca69045298ce5c11fd539682cff879cc1e664c245d1c64da929813e54241d11"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a06339f38e9ed3a64e4c4e43aec7f59084033647f908e4259d279a52d3757d09"}, - {file = "frozenlist-1.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b7f2f9f912dca3934c1baec2e4585a674ef16fe00218d833856408c48d5beee7"}, - {file = "frozenlist-1.4.1-cp38-cp38-win32.whl", hash = "sha256:e7004be74cbb7d9f34553a5ce5fb08be14fb33bc86f332fb71cbe5216362a497"}, - {file = "frozenlist-1.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:5a7d70357e7cee13f470c7883a063aae5fe209a493c57d86eb7f5a6f910fae09"}, - {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:bfa4a17e17ce9abf47a74ae02f32d014c5e9404b6d9ac7f729e01562bbee601e"}, - {file = "frozenlist-1.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b7e3ed87d4138356775346e6845cccbe66cd9e207f3cd11d2f0b9fd13681359d"}, - {file = "frozenlist-1.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c99169d4ff810155ca50b4da3b075cbde79752443117d89429595c2e8e37fed8"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:edb678da49d9f72c9f6c609fbe41a5dfb9a9282f9e6a2253d5a91e0fc382d7c0"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6db4667b187a6742b33afbbaf05a7bc551ffcf1ced0000a571aedbb4aa42fc7b"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55fdc093b5a3cb41d420884cdaf37a1e74c3c37a31f46e66286d9145d2063bd0"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82e8211d69a4f4bc360ea22cd6555f8e61a1bd211d1d5d39d3d228b48c83a897"}, - {file = "frozenlist-1.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89aa2c2eeb20957be2d950b85974b30a01a762f3308cd02bb15e1ad632e22dc7"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:9d3e0c25a2350080e9319724dede4f31f43a6c9779be48021a7f4ebde8b2d742"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7268252af60904bf52c26173cbadc3a071cece75f873705419c8681f24d3edea"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:0c250a29735d4f15321007fb02865f0e6b6a41a6b88f1f523ca1596ab5f50bd5"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:96ec70beabbd3b10e8bfe52616a13561e58fe84c0101dd031dc78f250d5128b9"}, - {file = "frozenlist-1.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:23b2d7679b73fe0e5a4560b672a39f98dfc6f60df63823b0a9970525325b95f6"}, - {file = "frozenlist-1.4.1-cp39-cp39-win32.whl", hash = "sha256:a7496bfe1da7fb1a4e1cc23bb67c58fab69311cc7d32b5a99c2007b4b2a0e932"}, - {file = "frozenlist-1.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:e6a20a581f9ce92d389a8c7d7c3dd47c81fd5d6e655c8dddf341e14aa48659d0"}, - {file = "frozenlist-1.4.1-py3-none-any.whl", hash = "sha256:04ced3e6a46b4cfffe20f9ae482818e34eba9b5fb0ce4056e4cc9b6e212d09b7"}, - {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, + {file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"}, + {file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"}, ] +[package.dependencies] +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.48.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + [[package]] name = "google-api-core" -version = "2.19.2" +version = "2.25.1" description = "Google API client core library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_api_core-2.19.2-py3-none-any.whl", hash = "sha256:53ec0258f2837dd53bbd3d3df50f5359281b3cc13f800c941dd15a9b5a415af4"}, - {file = "google_api_core-2.19.2.tar.gz", hash = "sha256:ca07de7e8aa1c98a8bfca9321890ad2340ef7f2eb136e558cee68f24b94b0a8f"}, + {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, + {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, ] [package.dependencies] -google-auth = ">=2.14.1,<3.0.dev0" -googleapis-common-protos = ">=1.56.2,<2.0.dev0" +google-auth = ">=2.14.1,<3.0.0" +googleapis-common-protos = ">=1.56.2,<2.0.0" grpcio = [ - {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, - {version = ">=1.33.2,<2.0dev", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0.0", optional = true, markers = "extra == \"grpc\""}, ] grpcio-status = [ - {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, - {version = ">=1.33.2,<2.0.dev0", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0.0", optional = true, markers = "extra == \"grpc\""}, +] +proto-plus = [ + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, ] -proto-plus = ">=1.22.3,<2.0.0dev" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" -requests = ">=2.18.0,<3.0.0.dev0" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" +requests = ">=2.18.0,<3.0.0" [package.extras] -grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] -grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] -grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] +grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\""] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] [[package]] name = "google-auth" -version = "2.34.0" +version = "2.40.3" description = "Google Authentication Library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_auth-2.34.0-py2.py3-none-any.whl", hash = "sha256:72fd4733b80b6d777dcde515628a9eb4a577339437012874ea286bca7261ee65"}, - {file = "google_auth-2.34.0.tar.gz", hash = "sha256:8eb87396435c19b20d32abd2f984e31c191a15284af72eb922f10e5bde9c04cc"}, + {file = "google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca"}, + {file = "google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77"}, ] [package.dependencies] @@ -525,53 +339,62 @@ pyasn1-modules = ">=0.2.1" rsa = ">=3.1.4,<5" [package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] enterprise-cert = ["cryptography", "pyopenssl"] -pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +pyjwt = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] +pyopenssl = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] reauth = ["pyu2f (>=0.1.5)"] -requests = ["requests (>=2.20.0,<3.0.0.dev0)"] +requests = ["requests (>=2.20.0,<3.0.0)"] +testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] +urllib3 = ["packaging", "urllib3"] [[package]] name = "google-cloud-appengine-logging" -version = "1.4.5" +version = "1.6.2" description = "Google Cloud Appengine Logging API client library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_cloud_appengine_logging-1.4.5-py2.py3-none-any.whl", hash = "sha256:344e0244404049b42164e4d6dc718ca2c81b393d066956e7cb85fd9407ed9c48"}, - {file = "google_cloud_appengine_logging-1.4.5.tar.gz", hash = "sha256:de7d766e5d67b19fc5833974b505b32d2a5bbdfb283fd941e320e7cfdae4cb83"}, + {file = "google_cloud_appengine_logging-1.6.2-py3-none-any.whl", hash = "sha256:2b28ed715e92b67e334c6fcfe1deb523f001919560257b25fc8fcda95fd63938"}, + {file = "google_cloud_appengine_logging-1.6.2.tar.gz", hash = "sha256:4890928464c98da9eecc7bf4e0542eba2551512c0265462c10f3a3d2a6424b90"}, ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" -proto-plus = ">=1.22.3,<2.0.0dev" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +proto-plus = [ + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.3,<2.0.0"}, +] +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [[package]] name = "google-cloud-audit-log" -version = "0.3.0" +version = "0.3.2" description = "Google Cloud Audit Protos" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_cloud_audit_log-0.3.0-py2.py3-none-any.whl", hash = "sha256:8340793120a1d5aa143605def8704ecdcead15106f754ef1381ae3bab533722f"}, - {file = "google_cloud_audit_log-0.3.0.tar.gz", hash = "sha256:901428b257020d8c1d1133e0fa004164a555e5a395c7ca3cdbb8486513df3a65"}, + {file = "google_cloud_audit_log-0.3.2-py3-none-any.whl", hash = "sha256:daaedfb947a0d77f524e1bd2b560242ab4836fe1afd6b06b92f152b9658554ed"}, + {file = "google_cloud_audit_log-0.3.2.tar.gz", hash = "sha256:2598f1533a7d7cdd6c7bf448c12e5519c1d53162d78784e10bcdd1df67791bc3"}, ] [package.dependencies] -googleapis-common-protos = ">=1.56.2,<2.0dev" -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +googleapis-common-protos = ">=1.56.2,<2.0.0" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [[package]] name = "google-cloud-core" -version = "2.4.1" +version = "2.4.3" description = "Google Cloud API client core library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073"}, - {file = "google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61"}, + {file = "google_cloud_core-2.4.3-py2.py3-none-any.whl", hash = "sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e"}, + {file = "google_cloud_core-2.4.3.tar.gz", hash = "sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53"}, ] [package.dependencies] @@ -583,219 +406,237 @@ grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] [[package]] name = "google-cloud-logging" -version = "3.11.3" +version = "3.12.1" description = "Stackdriver Logging API client library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_cloud_logging-3.11.3-py2.py3-none-any.whl", hash = "sha256:b8ec23f2998f76a58f8492db26a0f4151dd500425c3f08448586b85972f3c494"}, - {file = "google_cloud_logging-3.11.3.tar.gz", hash = "sha256:0a73cd94118875387d4535371d9e9426861edef8e44fba1261e86782d5b8d54f"}, + {file = "google_cloud_logging-3.12.1-py2.py3-none-any.whl", hash = "sha256:6817878af76ec4e7568976772839ab2c43ddfd18fbbf2ce32b13ef549cd5a862"}, + {file = "google_cloud_logging-3.12.1.tar.gz", hash = "sha256:36efc823985055b203904e83e1c8f9f999b3c64270bcda39d57386ca4effd678"}, ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" -google-cloud-appengine-logging = ">=0.1.3,<2.0.0dev" -google-cloud-audit-log = ">=0.2.4,<1.0.0dev" -google-cloud-core = ">=2.0.0,<3.0.0dev" -grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +google-cloud-appengine-logging = ">=0.1.3,<2.0.0" +google-cloud-audit-log = ">=0.3.1,<1.0.0" +google-cloud-core = ">=2.0.0,<3.0.0" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0" opentelemetry-api = ">=1.9.0" proto-plus = [ - {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""}, - {version = ">=1.22.0,<2.0.0dev", markers = "python_version < \"3.11\""}, + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.22.2,<2.0.0", markers = "python_version >= \"3.11\" and python_version < \"3.13\""}, + {version = ">=1.22.0,<2.0.0", markers = "python_version < \"3.11\""}, ] -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [[package]] name = "googleapis-common-protos" -version = "1.65.0" +version = "1.70.0" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "googleapis_common_protos-1.65.0-py2.py3-none-any.whl", hash = "sha256:2972e6c496f435b92590fd54045060867f3fe9be2c82ab148fc8885035479a63"}, - {file = "googleapis_common_protos-1.65.0.tar.gz", hash = "sha256:334a29d07cddc3aa01dee4988f9afd9b2916ee2ff49d6b757155dc0d197852c0"}, + {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, + {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, ] [package.dependencies] -grpcio = {version = ">=1.44.0,<2.0.0.dev0", optional = true, markers = "extra == \"grpc\""} -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [package.extras] -grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] +grpc = ["grpcio (>=1.44.0,<2.0.0)"] [[package]] name = "gotrue" -version = "2.10.0" +version = "2.12.3" description = "Python Client Library for Supabase Auth" optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "gotrue-2.10.0-py3-none-any.whl", hash = "sha256:768e58207488e5184ffbdc4351b7280d913daf97962f4e9f2cca05c80004b042"}, - {file = "gotrue-2.10.0.tar.gz", hash = "sha256:4edf4c251da3535f2b044e23deba221e848ca1210c17d0c7a9b19f79a1e3f3c0"}, + {file = "gotrue-2.12.3-py3-none-any.whl", hash = "sha256:b1a3c6a5fe3f92e854a026c4c19de58706a96fd5fbdcc3d620b2802f6a46a26b"}, + {file = "gotrue-2.12.3.tar.gz", hash = "sha256:f874cf9d0b2f0335bfbd0d6e29e3f7aff79998cd1c14d2ad814db8c06cee3852"}, ] [package.dependencies] -httpx = {version = ">=0.26,<0.28", extras = ["http2"]} +httpx = {version = ">=0.26,<0.29", extras = ["http2"]} pydantic = ">=1.10,<3" +pyjwt = ">=2.10.1,<3.0.0" [[package]] name = "grpc-google-iam-v1" -version = "0.13.1" +version = "0.14.2" description = "IAM API client library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "grpc-google-iam-v1-0.13.1.tar.gz", hash = "sha256:3ff4b2fd9d990965e410965253c0da6f66205d5a8291c4c31c6ebecca18a9001"}, - {file = "grpc_google_iam_v1-0.13.1-py2.py3-none-any.whl", hash = "sha256:c3e86151a981811f30d5e7330f271cee53e73bb87755e88cc3b6f0c7b5fe374e"}, + {file = "grpc_google_iam_v1-0.14.2-py3-none-any.whl", hash = "sha256:a3171468459770907926d56a440b2bb643eec1d7ba215f48f3ecece42b4d8351"}, + {file = "grpc_google_iam_v1-0.14.2.tar.gz", hash = "sha256:b3e1fc387a1a329e41672197d0ace9de22c78dd7d215048c4c78712073f7bd20"}, ] [package.dependencies] -googleapis-common-protos = {version = ">=1.56.0,<2.0.0dev", extras = ["grpc"]} -grpcio = ">=1.44.0,<2.0.0dev" -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +googleapis-common-protos = {version = ">=1.56.0,<2.0.0", extras = ["grpc"]} +grpcio = ">=1.44.0,<2.0.0" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [[package]] name = "grpcio" -version = "1.66.1" +version = "1.73.1" description = "HTTP/2-based RPC framework" optional = false -python-versions = ">=3.8" -files = [ - {file = "grpcio-1.66.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:4877ba180591acdf127afe21ec1c7ff8a5ecf0fe2600f0d3c50e8c4a1cbc6492"}, - {file = "grpcio-1.66.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:3750c5a00bd644c75f4507f77a804d0189d97a107eb1481945a0cf3af3e7a5ac"}, - {file = "grpcio-1.66.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:a013c5fbb12bfb5f927444b477a26f1080755a931d5d362e6a9a720ca7dbae60"}, - {file = "grpcio-1.66.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1b24c23d51a1e8790b25514157d43f0a4dce1ac12b3f0b8e9f66a5e2c4c132f"}, - {file = "grpcio-1.66.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7ffb8ea674d68de4cac6f57d2498fef477cef582f1fa849e9f844863af50083"}, - {file = "grpcio-1.66.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:307b1d538140f19ccbd3aed7a93d8f71103c5d525f3c96f8616111614b14bf2a"}, - {file = "grpcio-1.66.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:1c17ebcec157cfb8dd445890a03e20caf6209a5bd4ac5b040ae9dbc59eef091d"}, - {file = "grpcio-1.66.1-cp310-cp310-win32.whl", hash = "sha256:ef82d361ed5849d34cf09105d00b94b6728d289d6b9235513cb2fcc79f7c432c"}, - {file = "grpcio-1.66.1-cp310-cp310-win_amd64.whl", hash = "sha256:292a846b92cdcd40ecca46e694997dd6b9be6c4c01a94a0dfb3fcb75d20da858"}, - {file = "grpcio-1.66.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:c30aeceeaff11cd5ddbc348f37c58bcb96da8d5aa93fed78ab329de5f37a0d7a"}, - {file = "grpcio-1.66.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8a1e224ce6f740dbb6b24c58f885422deebd7eb724aff0671a847f8951857c26"}, - {file = "grpcio-1.66.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:a66fe4dc35d2330c185cfbb42959f57ad36f257e0cc4557d11d9f0a3f14311df"}, - {file = "grpcio-1.66.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3ba04659e4fce609de2658fe4dbf7d6ed21987a94460f5f92df7579fd5d0e22"}, - {file = "grpcio-1.66.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4573608e23f7e091acfbe3e84ac2045680b69751d8d67685ffa193a4429fedb1"}, - {file = "grpcio-1.66.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:7e06aa1f764ec8265b19d8f00140b8c4b6ca179a6dc67aa9413867c47e1fb04e"}, - {file = "grpcio-1.66.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3885f037eb11f1cacc41f207b705f38a44b69478086f40608959bf5ad85826dd"}, - {file = "grpcio-1.66.1-cp311-cp311-win32.whl", hash = "sha256:97ae7edd3f3f91480e48ede5d3e7d431ad6005bfdbd65c1b56913799ec79e791"}, - {file = "grpcio-1.66.1-cp311-cp311-win_amd64.whl", hash = "sha256:cfd349de4158d797db2bd82d2020554a121674e98fbe6b15328456b3bf2495bb"}, - {file = "grpcio-1.66.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:a92c4f58c01c77205df6ff999faa008540475c39b835277fb8883b11cada127a"}, - {file = "grpcio-1.66.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:fdb14bad0835914f325349ed34a51940bc2ad965142eb3090081593c6e347be9"}, - {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:f03a5884c56256e08fd9e262e11b5cfacf1af96e2ce78dc095d2c41ccae2c80d"}, - {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ca2559692d8e7e245d456877a85ee41525f3ed425aa97eb7a70fc9a79df91a0"}, - {file = "grpcio-1.66.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84ca1be089fb4446490dd1135828bd42a7c7f8421e74fa581611f7afdf7ab761"}, - {file = "grpcio-1.66.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:d639c939ad7c440c7b2819a28d559179a4508783f7e5b991166f8d7a34b52815"}, - {file = "grpcio-1.66.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:b9feb4e5ec8dc2d15709f4d5fc367794d69277f5d680baf1910fc9915c633524"}, - {file = "grpcio-1.66.1-cp312-cp312-win32.whl", hash = "sha256:7101db1bd4cd9b880294dec41a93fcdce465bdbb602cd8dc5bd2d6362b618759"}, - {file = "grpcio-1.66.1-cp312-cp312-win_amd64.whl", hash = "sha256:b0aa03d240b5539648d996cc60438f128c7f46050989e35b25f5c18286c86734"}, - {file = "grpcio-1.66.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:ecfe735e7a59e5a98208447293ff8580e9db1e890e232b8b292dc8bd15afc0d2"}, - {file = "grpcio-1.66.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:4825a3aa5648010842e1c9d35a082187746aa0cdbf1b7a2a930595a94fb10fce"}, - {file = "grpcio-1.66.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:f517fd7259fe823ef3bd21e508b653d5492e706e9f0ef82c16ce3347a8a5620c"}, - {file = "grpcio-1.66.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f1fe60d0772831d96d263b53d83fb9a3d050a94b0e94b6d004a5ad111faa5b5b"}, - {file = "grpcio-1.66.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31a049daa428f928f21090403e5d18ea02670e3d5d172581670be006100db9ef"}, - {file = "grpcio-1.66.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:6f914386e52cbdeb5d2a7ce3bf1fdfacbe9d818dd81b6099a05b741aaf3848bb"}, - {file = "grpcio-1.66.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bff2096bdba686019fb32d2dde45b95981f0d1490e054400f70fc9a8af34b49d"}, - {file = "grpcio-1.66.1-cp38-cp38-win32.whl", hash = "sha256:aa8ba945c96e73de29d25331b26f3e416e0c0f621e984a3ebdb2d0d0b596a3b3"}, - {file = "grpcio-1.66.1-cp38-cp38-win_amd64.whl", hash = "sha256:161d5c535c2bdf61b95080e7f0f017a1dfcb812bf54093e71e5562b16225b4ce"}, - {file = "grpcio-1.66.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:d0cd7050397b3609ea51727b1811e663ffda8bda39c6a5bb69525ef12414b503"}, - {file = "grpcio-1.66.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0e6c9b42ded5d02b6b1fea3a25f036a2236eeb75d0579bfd43c0018c88bf0a3e"}, - {file = "grpcio-1.66.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:c9f80f9fad93a8cf71c7f161778ba47fd730d13a343a46258065c4deb4b550c0"}, - {file = "grpcio-1.66.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5dd67ed9da78e5121efc5c510f0122a972216808d6de70953a740560c572eb44"}, - {file = "grpcio-1.66.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48b0d92d45ce3be2084b92fb5bae2f64c208fea8ceed7fccf6a7b524d3c4942e"}, - {file = "grpcio-1.66.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:4d813316d1a752be6f5c4360c49f55b06d4fe212d7df03253dfdae90c8a402bb"}, - {file = "grpcio-1.66.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9c9bebc6627873ec27a70fc800f6083a13c70b23a5564788754b9ee52c5aef6c"}, - {file = "grpcio-1.66.1-cp39-cp39-win32.whl", hash = "sha256:30a1c2cf9390c894c90bbc70147f2372130ad189cffef161f0432d0157973f45"}, - {file = "grpcio-1.66.1-cp39-cp39-win_amd64.whl", hash = "sha256:17663598aadbedc3cacd7bbde432f541c8e07d2496564e22b214b22c7523dac8"}, - {file = "grpcio-1.66.1.tar.gz", hash = "sha256:35334f9c9745add3e357e3372756fd32d925bd52c41da97f4dfdafbde0bf0ee2"}, +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "grpcio-1.73.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:2d70f4ddd0a823436c2624640570ed6097e40935c9194482475fe8e3d9754d55"}, + {file = "grpcio-1.73.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3841a8a5a66830261ab6a3c2a3dc539ed84e4ab019165f77b3eeb9f0ba621f26"}, + {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:628c30f8e77e0258ab788750ec92059fc3d6628590fb4b7cea8c102503623ed7"}, + {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67a0468256c9db6d5ecb1fde4bf409d016f42cef649323f0a08a72f352d1358b"}, + {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b84d65bbdebd5926eb5c53b0b9ec3b3f83408a30e4c20c373c5337b4219ec5"}, + {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c54796ca22b8349cc594d18b01099e39f2b7ffb586ad83217655781a350ce4da"}, + {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:75fc8e543962ece2f7ecd32ada2d44c0c8570ae73ec92869f9af8b944863116d"}, + {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6a6037891cd2b1dd1406b388660522e1565ed340b1fea2955b0234bdd941a862"}, + {file = "grpcio-1.73.1-cp310-cp310-win32.whl", hash = "sha256:cce7265b9617168c2d08ae570fcc2af4eaf72e84f8c710ca657cc546115263af"}, + {file = "grpcio-1.73.1-cp310-cp310-win_amd64.whl", hash = "sha256:6a2b372e65fad38842050943f42ce8fee00c6f2e8ea4f7754ba7478d26a356ee"}, + {file = "grpcio-1.73.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:ba2cea9f7ae4bc21f42015f0ec98f69ae4179848ad744b210e7685112fa507a1"}, + {file = "grpcio-1.73.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d74c3f4f37b79e746271aa6cdb3a1d7e4432aea38735542b23adcabaaee0c097"}, + {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:5b9b1805a7d61c9e90541cbe8dfe0a593dfc8c5c3a43fe623701b6a01b01d710"}, + {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3215f69a0670a8cfa2ab53236d9e8026bfb7ead5d4baabe7d7dc11d30fda967"}, + {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc5eccfd9577a5dc7d5612b2ba90cca4ad14c6d949216c68585fdec9848befb1"}, + {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dc7d7fd520614fce2e6455ba89791458020a39716951c7c07694f9dbae28e9c0"}, + {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:105492124828911f85127e4825d1c1234b032cb9d238567876b5515d01151379"}, + {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:610e19b04f452ba6f402ac9aa94eb3d21fbc94553368008af634812c4a85a99e"}, + {file = "grpcio-1.73.1-cp311-cp311-win32.whl", hash = "sha256:d60588ab6ba0ac753761ee0e5b30a29398306401bfbceffe7d68ebb21193f9d4"}, + {file = "grpcio-1.73.1-cp311-cp311-win_amd64.whl", hash = "sha256:6957025a4608bb0a5ff42abd75bfbb2ed99eda29d5992ef31d691ab54b753643"}, + {file = "grpcio-1.73.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:921b25618b084e75d424a9f8e6403bfeb7abef074bb6c3174701e0f2542debcf"}, + {file = "grpcio-1.73.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:277b426a0ed341e8447fbf6c1d6b68c952adddf585ea4685aa563de0f03df887"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:96c112333309493c10e118d92f04594f9055774757f5d101b39f8150f8c25582"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f48e862aed925ae987eb7084409a80985de75243389dc9d9c271dd711e589918"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83a6c2cce218e28f5040429835fa34a29319071079e3169f9543c3fbeff166d2"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:65b0458a10b100d815a8426b1442bd17001fdb77ea13665b2f7dc9e8587fdc6b"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0a9f3ea8dce9eae9d7cb36827200133a72b37a63896e0e61a9d5ec7d61a59ab1"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de18769aea47f18e782bf6819a37c1c528914bfd5683b8782b9da356506190c8"}, + {file = "grpcio-1.73.1-cp312-cp312-win32.whl", hash = "sha256:24e06a5319e33041e322d32c62b1e728f18ab8c9dbc91729a3d9f9e3ed336642"}, + {file = "grpcio-1.73.1-cp312-cp312-win_amd64.whl", hash = "sha256:303c8135d8ab176f8038c14cc10d698ae1db9c480f2b2823f7a987aa2a4c5646"}, + {file = "grpcio-1.73.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b310824ab5092cf74750ebd8a8a8981c1810cb2b363210e70d06ef37ad80d4f9"}, + {file = "grpcio-1.73.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:8f5a6df3fba31a3485096ac85b2e34b9666ffb0590df0cd044f58694e6a1f6b5"}, + {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:052e28fe9c41357da42250a91926a3e2f74c046575c070b69659467ca5aa976b"}, + {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c0bf15f629b1497436596b1cbddddfa3234273490229ca29561209778ebe182"}, + {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ab860d5bfa788c5a021fba264802e2593688cd965d1374d31d2b1a34cacd854"}, + {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ad1d958c31cc91ab050bd8a91355480b8e0683e21176522bacea225ce51163f2"}, + {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f43ffb3bd415c57224c7427bfb9e6c46a0b6e998754bfa0d00f408e1873dcbb5"}, + {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:686231cdd03a8a8055f798b2b54b19428cdf18fa1549bee92249b43607c42668"}, + {file = "grpcio-1.73.1-cp313-cp313-win32.whl", hash = "sha256:89018866a096e2ce21e05eabed1567479713ebe57b1db7cbb0f1e3b896793ba4"}, + {file = "grpcio-1.73.1-cp313-cp313-win_amd64.whl", hash = "sha256:4a68f8c9966b94dff693670a5cf2b54888a48a5011c5d9ce2295a1a1465ee84f"}, + {file = "grpcio-1.73.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:b4adc97d2d7f5c660a5498bda978ebb866066ad10097265a5da0511323ae9f50"}, + {file = "grpcio-1.73.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:c45a28a0cfb6ddcc7dc50a29de44ecac53d115c3388b2782404218db51cb2df3"}, + {file = "grpcio-1.73.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:10af9f2ab98a39f5b6c1896c6fc2036744b5b41d12739d48bed4c3e15b6cf900"}, + {file = "grpcio-1.73.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:45cf17dcce5ebdb7b4fe9e86cb338fa99d7d1bb71defc78228e1ddf8d0de8cbb"}, + {file = "grpcio-1.73.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c502c2e950fc7e8bf05c047e8a14522ef7babac59abbfde6dbf46b7a0d9c71e"}, + {file = "grpcio-1.73.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6abfc0f9153dc4924536f40336f88bd4fe7bd7494f028675e2e04291b8c2c62a"}, + {file = "grpcio-1.73.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ed451a0e39c8e51eb1612b78686839efd1a920666d1666c1adfdb4fd51680c0f"}, + {file = "grpcio-1.73.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:07f08705a5505c9b5b0cbcbabafb96462b5a15b7236bbf6bbcc6b0b91e1cbd7e"}, + {file = "grpcio-1.73.1-cp39-cp39-win32.whl", hash = "sha256:ad5c958cc3d98bb9d71714dc69f1c13aaf2f4b53e29d4cc3f1501ef2e4d129b2"}, + {file = "grpcio-1.73.1-cp39-cp39-win_amd64.whl", hash = "sha256:42f0660bce31b745eb9d23f094a332d31f210dcadd0fc8e5be7e4c62a87ce86b"}, + {file = "grpcio-1.73.1.tar.gz", hash = "sha256:7fce2cd1c0c1116cf3850564ebfc3264fba75d3c74a7414373f1238ea365ef87"}, ] [package.extras] -protobuf = ["grpcio-tools (>=1.66.1)"] +protobuf = ["grpcio-tools (>=1.73.1)"] [[package]] name = "grpcio-status" -version = "1.66.1" +version = "1.73.1" description = "Status proto mapping for gRPC" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "grpcio_status-1.66.1-py3-none-any.whl", hash = "sha256:cf9ed0b4a83adbe9297211c95cb5488b0cd065707e812145b842c85c4782ff02"}, - {file = "grpcio_status-1.66.1.tar.gz", hash = "sha256:b3f7d34ccc46d83fea5261eea3786174459f763c31f6e34f1d24eba6d515d024"}, + {file = "grpcio_status-1.73.1-py3-none-any.whl", hash = "sha256:538595c32a6c819c32b46a621a51e9ae4ffcd7e7e1bce35f728ef3447e9809b6"}, + {file = "grpcio_status-1.73.1.tar.gz", hash = "sha256:928f49ccf9688db5f20cd9e45c4578a1d01ccca29aeaabf066f2ac76aa886668"}, ] [package.dependencies] googleapis-common-protos = ">=1.5.5" -grpcio = ">=1.66.1" -protobuf = ">=5.26.1,<6.0dev" +grpcio = ">=1.73.1" +protobuf = ">=6.30.0,<7.0.0" [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] name = "h2" -version = "4.1.0" -description = "HTTP/2 State-Machine based protocol implementation" +version = "4.2.0" +description = "Pure-Python HTTP/2 protocol implementation" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"}, - {file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"}, + {file = "h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0"}, + {file = "h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f"}, ] [package.dependencies] -hpack = ">=4.0,<5" -hyperframe = ">=6.0,<7" +hpack = ">=4.1,<5" +hyperframe = ">=6.1,<7" [[package]] name = "hpack" -version = "4.0.0" -description = "Pure-Python HPACK header compression" +version = "4.1.0" +description = "Pure-Python HPACK header encoding" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"}, - {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, + {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, + {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, ] [[package]] name = "httpcore" -version = "1.0.5" +version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, - {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [package.dependencies] certifi = "*" -h11 = ">=0.13,<0.15" +h11 = ">=0.16" [package.extras] asyncio = ["anyio (>=4.0,<5.0)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] -trio = ["trio (>=0.22.0,<0.26.0)"] +trio = ["trio (>=0.22.0,<1.0)"] [[package]] name = "httpx" -version = "0.27.2" +version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, - {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, ] [package.dependencies] @@ -804,10 +645,9 @@ certifi = "*" h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} httpcore = "==1.*" idna = "*" -sniffio = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] @@ -815,253 +655,205 @@ zstd = ["zstandard (>=0.18.0)"] [[package]] name = "hyperframe" -version = "6.0.1" -description = "HTTP/2 framing layer for Python" +version = "6.1.0" +description = "Pure-Python HTTP/2 framing" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"}, - {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, + {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, + {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, ] [[package]] name = "idna" -version = "3.8" +version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ - {file = "idna-3.8-py3-none-any.whl", hash = "sha256:050b4e5baadcd44d760cedbd2b8e639f2ff89bbc7a5730fcc662954303377aac"}, - {file = "idna-3.8.tar.gz", hash = "sha256:d838c2c0ed6fced7693d5e8ab8e734d5f8fda53a039c0164afb0b82e771e3603"}, + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, ] +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "importlib-metadata" -version = "8.4.0" +version = "8.7.0" description = "Read metadata from Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "importlib_metadata-8.4.0-py3-none-any.whl", hash = "sha256:66f342cc6ac9818fc6ff340576acd24d65ba0b3efabb2b4ac08b598965a4a2f1"}, - {file = "importlib_metadata-8.4.0.tar.gz", hash = "sha256:9a547d3bc3608b025f93d403fdd1aae741c24fbb8314df4b155675742ce303c5"}, + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, ] [package.dependencies] -zipp = ">=0.5" +zipp = ">=3.20" [package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +type = ["pytest-mypy"] [[package]] name = "iniconfig" -version = "2.0.0" +version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, ] [[package]] -name = "multidict" -version = "6.1.0" -description = "multidict implementation" +name = "launchdarkly-eventsource" +version = "1.3.0" +description = "LaunchDarkly SSE Client" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "launchdarkly_eventsource-1.3.0-py3-none-any.whl", hash = "sha256:7afecee7a0f306630e4364994e0816abbbdbaf143fe43b3428ed122fa4e0d487"}, + {file = "launchdarkly_eventsource-1.3.0.tar.gz", hash = "sha256:5029199b2c344aee2806bbe8784965e9c0197d052d0bab7e1396a94c159f6bc8"}, +] + +[package.dependencies] +urllib3 = ">=1.26.0,<3" + +[[package]] +name = "launchdarkly-server-sdk" +version = "9.12.0" +description = "LaunchDarkly SDK for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, - {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, - {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"}, - {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"}, - {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"}, - {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"}, - {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"}, - {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"}, - {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"}, - {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"}, - {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"}, - {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"}, - {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"}, - {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"}, - {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"}, - {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"}, - {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, + {file = "launchdarkly_server_sdk-9.12.0-py3-none-any.whl", hash = "sha256:c7351e26e6b695b968d49888e967691aae60ded82d73a41a74d59d11252a1413"}, + {file = "launchdarkly_server_sdk-9.12.0.tar.gz", hash = "sha256:219b100d7569e4f528b6028cbd2a5d61f13539f2b44ed43fc173bb4f456ee580"}, ] [package.dependencies] -typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} +certifi = ">=2018.4.16" +expiringdict = ">=1.1.4" +launchdarkly-eventsource = ">=1.2.4,<2.0.0" +pyRFC3339 = ">=1.0" +semver = ">=2.10.2" +urllib3 = ">=1.26.0,<3" + +[package.extras] +consul = ["python-consul (>=1.0.1)"] +dynamodb = ["boto3 (>=1.9.71)"] +redis = ["redis (>=2.10.5)"] +test-filesource = ["pyyaml (>=5.3.1)", "watchdog (>=3.0.0)"] [[package]] name = "opentelemetry-api" -version = "1.27.0" +version = "1.35.0" description = "OpenTelemetry Python API" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "opentelemetry_api-1.27.0-py3-none-any.whl", hash = "sha256:953d5871815e7c30c81b56d910c707588000fff7a3ca1c73e6531911d53065e7"}, - {file = "opentelemetry_api-1.27.0.tar.gz", hash = "sha256:ed673583eaa5f81b5ce5e86ef7cdaf622f88ef65f0b9aab40b843dcae5bef342"}, + {file = "opentelemetry_api-1.35.0-py3-none-any.whl", hash = "sha256:c4ea7e258a244858daf18474625e9cc0149b8ee354f37843415771a40c25ee06"}, + {file = "opentelemetry_api-1.35.0.tar.gz", hash = "sha256:a111b959bcfa5b4d7dffc2fbd6a241aa72dd78dd8e79b5b1662bda896c5d2ffe"}, ] [package.dependencies] -deprecated = ">=1.2.6" -importlib-metadata = ">=6.0,<=8.4.0" +importlib-metadata = ">=6.0,<8.8.0" +typing-extensions = ">=4.5.0" [[package]] name = "packaging" -version = "24.1" +version = "25.0" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, - {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, ] [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "postgrest" -version = "0.18.0" +version = "1.1.1" description = "PostgREST client for Python. This library provides an ORM interface to PostgREST." optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "postgrest-0.18.0-py3-none-any.whl", hash = "sha256:200baad0d23fee986b3a0ffd3e07bfe0cdd40e09760f11e8e13a6c0c2376d5fa"}, - {file = "postgrest-0.18.0.tar.gz", hash = "sha256:29c1a94801a17eb9ad590189993fe5a7a6d8c1bfc11a3c9d0ce7ba146454ebb3"}, + {file = "postgrest-1.1.1-py3-none-any.whl", hash = "sha256:98a6035ee1d14288484bfe36235942c5fb2d26af6d8120dfe3efbe007859251a"}, + {file = "postgrest-1.1.1.tar.gz", hash = "sha256:f3bb3e8c4602775c75c844a31f565f5f3dd584df4d36d683f0b67d01a86be322"}, ] [package.dependencies] deprecation = ">=2.1.0,<3.0.0" -httpx = {version = ">=0.26,<0.28", extras = ["http2"]} +httpx = {version = ">=0.26,<0.29", extras = ["http2"]} pydantic = ">=1.9,<3.0" strenum = {version = ">=0.4.9,<0.5.0", markers = "python_version < \"3.11\""} [[package]] name = "proto-plus" -version = "1.24.0" -description = "Beautiful, Pythonic protocol buffers." +version = "1.26.1" +description = "Beautiful, Pythonic protocol buffers" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "proto-plus-1.24.0.tar.gz", hash = "sha256:30b72a5ecafe4406b0d339db35b56c4059064e69227b8c3bda7462397f966445"}, - {file = "proto_plus-1.24.0-py3-none-any.whl", hash = "sha256:402576830425e5f6ce4c2a6702400ac79897dab0b4343821aa5188b0fab81a12"}, + {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, + {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, ] [package.dependencies] -protobuf = ">=3.19.0,<6.0.0dev" +protobuf = ">=3.19.0,<7.0.0" [package.extras] testing = ["google-api-core (>=1.31.5)"] [[package]] name = "protobuf" -version = "5.28.0" +version = "6.31.1" description = "" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "protobuf-5.28.0-cp310-abi3-win32.whl", hash = "sha256:66c3edeedb774a3508ae70d87b3a19786445fe9a068dd3585e0cefa8a77b83d0"}, - {file = "protobuf-5.28.0-cp310-abi3-win_amd64.whl", hash = "sha256:6d7cc9e60f976cf3e873acb9a40fed04afb5d224608ed5c1a105db4a3f09c5b6"}, - {file = "protobuf-5.28.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:532627e8fdd825cf8767a2d2b94d77e874d5ddb0adefb04b237f7cc296748681"}, - {file = "protobuf-5.28.0-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:018db9056b9d75eb93d12a9d35120f97a84d9a919bcab11ed56ad2d399d6e8dd"}, - {file = "protobuf-5.28.0-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:6206afcb2d90181ae8722798dcb56dc76675ab67458ac24c0dd7d75d632ac9bd"}, - {file = "protobuf-5.28.0-cp38-cp38-win32.whl", hash = "sha256:eef7a8a2f4318e2cb2dee8666d26e58eaf437c14788f3a2911d0c3da40405ae8"}, - {file = "protobuf-5.28.0-cp38-cp38-win_amd64.whl", hash = "sha256:d001a73c8bc2bf5b5c1360d59dd7573744e163b3607fa92788b7f3d5fefbd9a5"}, - {file = "protobuf-5.28.0-cp39-cp39-win32.whl", hash = "sha256:dde9fcaa24e7a9654f4baf2a55250b13a5ea701493d904c54069776b99a8216b"}, - {file = "protobuf-5.28.0-cp39-cp39-win_amd64.whl", hash = "sha256:853db610214e77ee817ecf0514e0d1d052dff7f63a0c157aa6eabae98db8a8de"}, - {file = "protobuf-5.28.0-py3-none-any.whl", hash = "sha256:510ed78cd0980f6d3218099e874714cdf0d8a95582e7b059b06cabad855ed0a0"}, - {file = "protobuf-5.28.0.tar.gz", hash = "sha256:dde74af0fa774fa98892209992295adbfb91da3fa98c8f67a88afe8f5a349add"}, + {file = "protobuf-6.31.1-cp310-abi3-win32.whl", hash = "sha256:7fa17d5a29c2e04b7d90e5e32388b8bfd0e7107cd8e616feef7ed3fa6bdab5c9"}, + {file = "protobuf-6.31.1-cp310-abi3-win_amd64.whl", hash = "sha256:426f59d2964864a1a366254fa703b8632dcec0790d8862d30034d8245e1cd447"}, + {file = "protobuf-6.31.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:6f1227473dc43d44ed644425268eb7c2e488ae245d51c6866d19fe158e207402"}, + {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:a40fc12b84c154884d7d4c4ebd675d5b3b5283e155f324049ae396b95ddebc39"}, + {file = "protobuf-6.31.1-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:4ee898bf66f7a8b0bd21bce523814e6fbd8c6add948045ce958b73af7e8878c6"}, + {file = "protobuf-6.31.1-cp39-cp39-win32.whl", hash = "sha256:0414e3aa5a5f3ff423828e1e6a6e907d6c65c1d5b7e6e975793d5590bdeecc16"}, + {file = "protobuf-6.31.1-cp39-cp39-win_amd64.whl", hash = "sha256:8764cf4587791e7564051b35524b72844f845ad0bb011704c3736cce762d8fe9"}, + {file = "protobuf-6.31.1-py3-none-any.whl", hash = "sha256:720a6c7e6b77288b85063569baae8536671b39f15cc22037ec7045658d80489e"}, + {file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"}, ] [[package]] @@ -1070,6 +862,7 @@ version = "0.6.1" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, @@ -1077,145 +870,148 @@ files = [ [[package]] name = "pyasn1-modules" -version = "0.4.1" +version = "0.4.2" description = "A collection of ASN.1-based protocols modules" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, - {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, + {file = "pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a"}, + {file = "pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6"}, ] [package.dependencies] -pyasn1 = ">=0.4.6,<0.7.0" +pyasn1 = ">=0.6.1,<0.7.0" [[package]] name = "pydantic" -version = "2.10.3" +version = "2.11.7" description = "Data validation using Python type hints" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d"}, - {file = "pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9"}, + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.27.1" +pydantic-core = "2.33.2" typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" -version = "2.27.1" +version = "2.33.2" description = "Core functionality for Pydantic validation and serialization" optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a"}, - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206"}, - {file = "pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c"}, - {file = "pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc"}, - {file = "pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9"}, - {file = "pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5"}, - {file = "pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae"}, - {file = "pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c"}, - {file = "pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16"}, - {file = "pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23"}, - {file = "pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05"}, - {file = "pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337"}, - {file = "pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b"}, - {file = "pydantic_core-2.27.1-cp38-none-win32.whl", hash = "sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618"}, - {file = "pydantic_core-2.27.1-cp38-none-win_amd64.whl", hash = "sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131"}, - {file = "pydantic_core-2.27.1-cp39-none-win32.whl", hash = "sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3"}, - {file = "pydantic_core-2.27.1-cp39-none-win_amd64.whl", hash = "sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840"}, - {file = "pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235"}, +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, ] [package.dependencies] @@ -1223,30 +1019,50 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pydantic-settings" -version = "2.7.0" +version = "2.10.1" description = "Settings management using Pydantic" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pydantic_settings-2.7.0-py3-none-any.whl", hash = "sha256:e00c05d5fa6cbbb227c84bd7487c5c1065084119b750df7c8c1a554aed236eb5"}, - {file = "pydantic_settings-2.7.0.tar.gz", hash = "sha256:ac4bfd4a36831a48dbf8b2d9325425b549a0a6f18cea118436d728eb4f1c4d66"}, + {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"}, + {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"}, ] [package.dependencies] pydantic = ">=2.7.0" python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" [package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] yaml = ["pyyaml (>=6.0.1)"] +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyjwt" version = "2.10.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, @@ -1258,40 +1074,56 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +[[package]] +name = "pyrfc3339" +version = "2.0.1" +description = "Generate and parse RFC 3339 timestamps" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "pyRFC3339-2.0.1-py3-none-any.whl", hash = "sha256:30b70a366acac3df7386b558c21af871522560ed7f3f73cf344b8c2cbb8b0c9d"}, + {file = "pyrfc3339-2.0.1.tar.gz", hash = "sha256:e47843379ea35c1296c3b6c67a948a1a490ae0584edfcbdea0eaffb5dd29960b"}, +] + [[package]] name = "pytest" -version = "8.3.3" +version = "8.4.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, - {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" pluggy = ">=1.5,<2" +pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.25.0" +version = "1.1.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3"}, - {file = "pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609"}, + {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, + {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, ] [package.dependencies] +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} pytest = ">=8.2,<9" [package.extras] @@ -1300,13 +1132,14 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-mock" -version = "3.14.0" +version = "3.14.1" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, - {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, + {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, + {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, ] [package.dependencies] @@ -1321,6 +1154,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -1331,13 +1165,14 @@ six = ">=1.5" [[package]] name = "python-dotenv" -version = "1.0.1" +version = "1.1.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, ] [package.extras] @@ -1345,53 +1180,55 @@ cli = ["click (>=5.0)"] [[package]] name = "realtime" -version = "2.0.2" +version = "2.5.3" description = "" optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "realtime-2.0.2-py3-none-any.whl", hash = "sha256:2634c915bc38807f2013f21e8bcc4d2f79870dfd81460ddb9393883d0489928a"}, - {file = "realtime-2.0.2.tar.gz", hash = "sha256:519da9325b3b8102139d51785013d592f6b2403d81fa21d838a0b0234723ed7d"}, + {file = "realtime-2.5.3-py3-none-any.whl", hash = "sha256:eb0994636946eff04c4c7f044f980c8c633c7eb632994f549f61053a474ac970"}, + {file = "realtime-2.5.3.tar.gz", hash = "sha256:0587594f3bc1c84bf007ff625075b86db6528843e03250dc84f4f2808be3d99a"}, ] [package.dependencies] -aiohttp = ">=3.10.2,<4.0.0" -python-dateutil = ">=2.8.1,<3.0.0" -typing-extensions = ">=4.12.2,<5.0.0" -websockets = ">=11,<13" +typing-extensions = ">=4.14.0,<5.0.0" +websockets = ">=11,<16" [[package]] name = "redis" -version = "5.2.1" +version = "6.2.0" description = "Python client for Redis database and key-value store" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4"}, - {file = "redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f"}, + {file = "redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e"}, + {file = "redis-6.2.0.tar.gz", hash = "sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977"}, ] [package.dependencies] async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} [package.extras] -hiredis = ["hiredis (>=3.0.0)"] -ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] +hiredis = ["hiredis (>=3.2.0)"] +jwt = ["pyjwt (>=2.9.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] [[package]] name = "requests" -version = "2.32.3" +version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -1401,13 +1238,14 @@ use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] name = "rsa" -version = "4.9" +version = "4.9.1" description = "Pure-Python RSA implementation" optional = false -python-versions = ">=3.6,<4" +python-versions = "<4,>=3.6" +groups = ["main"] files = [ - {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, - {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, ] [package.dependencies] @@ -1415,40 +1253,55 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.8.3" +version = "0.12.9" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.12.9-py3-none-linux_armv6l.whl", hash = "sha256:fcebc6c79fcae3f220d05585229463621f5dbf24d79fdc4936d9302e177cfa3e"}, + {file = "ruff-0.12.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aed9d15f8c5755c0e74467731a007fcad41f19bcce41cd75f768bbd687f8535f"}, + {file = "ruff-0.12.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5b15ea354c6ff0d7423814ba6d44be2807644d0c05e9ed60caca87e963e93f70"}, + {file = "ruff-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d596c2d0393c2502eaabfef723bd74ca35348a8dac4267d18a94910087807c53"}, + {file = "ruff-0.12.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1b15599931a1a7a03c388b9c5df1bfa62be7ede6eb7ef753b272381f39c3d0ff"}, + {file = "ruff-0.12.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3d02faa2977fb6f3f32ddb7828e212b7dd499c59eb896ae6c03ea5c303575756"}, + {file = "ruff-0.12.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:17d5b6b0b3a25259b69ebcba87908496e6830e03acfb929ef9fd4c58675fa2ea"}, + {file = "ruff-0.12.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:72db7521860e246adbb43f6ef464dd2a532ef2ef1f5dd0d470455b8d9f1773e0"}, + {file = "ruff-0.12.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a03242c1522b4e0885af63320ad754d53983c9599157ee33e77d748363c561ce"}, + {file = "ruff-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fc83e4e9751e6c13b5046d7162f205d0a7bac5840183c5beebf824b08a27340"}, + {file = "ruff-0.12.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:881465ed56ba4dd26a691954650de6ad389a2d1fdb130fe51ff18a25639fe4bb"}, + {file = "ruff-0.12.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:43f07a3ccfc62cdb4d3a3348bf0588358a66da756aa113e071b8ca8c3b9826af"}, + {file = "ruff-0.12.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:07adb221c54b6bba24387911e5734357f042e5669fa5718920ee728aba3cbadc"}, + {file = "ruff-0.12.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f5cd34fabfdea3933ab85d72359f118035882a01bff15bd1d2b15261d85d5f66"}, + {file = "ruff-0.12.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f6be1d2ca0686c54564da8e7ee9e25f93bdd6868263805f8c0b8fc6a449db6d7"}, + {file = "ruff-0.12.9-py3-none-win32.whl", hash = "sha256:cc7a37bd2509974379d0115cc5608a1a4a6c4bff1b452ea69db83c8855d53f93"}, + {file = "ruff-0.12.9-py3-none-win_amd64.whl", hash = "sha256:6fb15b1977309741d7d098c8a3cb7a30bc112760a00fb6efb7abc85f00ba5908"}, + {file = "ruff-0.12.9-py3-none-win_arm64.whl", hash = "sha256:63c8c819739d86b96d500cce885956a1a48ab056bbcbc61b747ad494b2485089"}, + {file = "ruff-0.12.9.tar.gz", hash = "sha256:fbd94b2e3c623f659962934e52c2bea6fc6da11f667a427a368adaf3af2c866a"}, +] + +[[package]] +name = "semver" +version = "3.0.4" +description = "Python helper for Semantic Versioning (https://semver.org)" +optional = false +python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6"}, - {file = "ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939"}, - {file = "ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea"}, - {file = "ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964"}, - {file = "ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9"}, - {file = "ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936"}, - {file = "ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3"}, + {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, + {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, ] [[package]] name = "six" -version = "1.16.0" +version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] files = [ - {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, - {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, + {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, + {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, ] [[package]] @@ -1457,24 +1310,46 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, ] +[[package]] +name = "starlette" +version = "0.47.1" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527"}, + {file = "starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + [[package]] name = "storage3" -version = "0.9.0" +version = "0.12.0" description = "Supabase Storage client for Python." optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "storage3-0.9.0-py3-none-any.whl", hash = "sha256:8b2fb91f0c61583a2f4eac74a8bae67e00d41ff38095c8a6cd3f2ce5e0ab76e7"}, - {file = "storage3-0.9.0.tar.gz", hash = "sha256:e16697f60894c94e1d9df0d2e4af783c1b3f7dd08c9013d61978825c624188c4"}, + {file = "storage3-0.12.0-py3-none-any.whl", hash = "sha256:1c4585693ca42243ded1512b58e54c697111e91a20916cd14783eebc37e7c87d"}, + {file = "storage3-0.12.0.tar.gz", hash = "sha256:94243f20922d57738bf42e96b9f5582b4d166e8bf209eccf20b146909f3f71b0"}, ] [package.dependencies] -httpx = {version = ">=0.26,<0.28", extras = ["http2"]} +deprecation = ">=2.1.0,<3.0.0" +httpx = {version = ">=0.26,<0.29", extras = ["http2"]} python-dateutil = ">=2.8.2,<3.0.0" [[package]] @@ -1483,6 +1358,7 @@ version = "0.4.15" description = "An Enum that inherits from str." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659"}, {file = "StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff"}, @@ -1495,361 +1371,248 @@ test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"] [[package]] name = "supabase" -version = "2.10.0" +version = "2.16.0" description = "Supabase client for Python." optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "supabase-2.10.0-py3-none-any.whl", hash = "sha256:183fb23c04528593f8f81c24ceb8178f3a56bff40fec7ed873b6c55ebc2e420a"}, - {file = "supabase-2.10.0.tar.gz", hash = "sha256:9ac095f8947bf60780e67c0edcbab53e2db3f6f3f022329397b093500bf2607c"}, + {file = "supabase-2.16.0-py3-none-any.whl", hash = "sha256:99065caab3d90a56650bf39fbd0e49740995da3738ab28706c61bd7f2401db55"}, + {file = "supabase-2.16.0.tar.gz", hash = "sha256:98f3810158012d4ec0e3083f2e5515f5e10b32bd71e7d458662140e963c1d164"}, ] [package.dependencies] -gotrue = ">=2.10.0,<3.0.0" -httpx = ">=0.26,<0.28" -postgrest = ">=0.18,<0.19" -realtime = ">=2.0.0,<3.0.0" -storage3 = ">=0.9.0,<0.10.0" -supafunc = ">=0.7.0,<0.8.0" +gotrue = ">=2.11.0,<3.0.0" +httpx = ">=0.26,<0.29" +postgrest = ">0.19,<1.2" +realtime = ">=2.4.0,<2.6.0" +storage3 = ">=0.10,<0.13" +supafunc = ">=0.9,<0.11" [[package]] name = "supafunc" -version = "0.7.0" +version = "0.10.1" description = "Library for Supabase Functions" optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "supafunc-0.7.0-py3-none-any.whl", hash = "sha256:4160260dc02bdd906be1e2ffd7cb3ae8b74ae437c892bb475352b6a99d9ff8eb"}, - {file = "supafunc-0.7.0.tar.gz", hash = "sha256:5b1c415fba1395740b2b4eedd1d786384bd58b98f6333a11ba7889820a48b6a7"}, + {file = "supafunc-0.10.1-py3-none-any.whl", hash = "sha256:26df9bd25ff2ef56cb5bfb8962de98f43331f7f8ff69572bac3ed9c3a9672040"}, + {file = "supafunc-0.10.1.tar.gz", hash = "sha256:a5b33c8baecb6b5297d25da29a2503e2ec67ee6986f3d44c137e651b8a59a17d"}, ] [package.dependencies] -httpx = {version = ">=0.26,<0.28", extras = ["http2"]} +httpx = {version = ">=0.26,<0.29", extras = ["http2"]} +strenum = ">=0.4.15,<0.5.0" [[package]] name = "tomli" -version = "2.1.0" +version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" -files = [ - {file = "tomli-2.1.0-py3-none-any.whl", hash = "sha256:a5c57c3d1c56f5ccdf89f6523458f60ef716e210fc47c4cfb188c5ba473e0391"}, - {file = "tomli-2.1.0.tar.gz", hash = "sha256:3f646cae2aec94e17d04973e4249548320197cfabdf130015d023de4b74d8ab8"}, +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] [[package]] name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.14.1" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.1" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + [[package]] name = "urllib3" -version = "2.2.2" +version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "urllib3-2.2.2-py3-none-any.whl", hash = "sha256:a448b2f64d686155468037e1ace9f2d2199776e17f0a46610480d311f73e3472"}, - {file = "urllib3-2.2.2.tar.gz", hash = "sha256:dd505485549a7a552833da5e6063639d0d177c04f23bc3864e41e5dc5f612168"}, + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [[package]] -name = "websockets" -version = "12.0" -description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" -optional = false -python-versions = ">=3.8" -files = [ - {file = "websockets-12.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d554236b2a2006e0ce16315c16eaa0d628dab009c33b63ea03f41c6107958374"}, - {file = "websockets-12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2d225bb6886591b1746b17c0573e29804619c8f755b5598d875bb4235ea639be"}, - {file = "websockets-12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:eb809e816916a3b210bed3c82fb88eaf16e8afcf9c115ebb2bacede1797d2547"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c588f6abc13f78a67044c6b1273a99e1cf31038ad51815b3b016ce699f0d75c2"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5aa9348186d79a5f232115ed3fa9020eab66d6c3437d72f9d2c8ac0c6858c558"}, - {file = "websockets-12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6350b14a40c95ddd53e775dbdbbbc59b124a5c8ecd6fbb09c2e52029f7a9f480"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:70ec754cc2a769bcd218ed8d7209055667b30860ffecb8633a834dde27d6307c"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6e96f5ed1b83a8ddb07909b45bd94833b0710f738115751cdaa9da1fb0cb66e8"}, - {file = "websockets-12.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:4d87be612cbef86f994178d5186add3d94e9f31cc3cb499a0482b866ec477603"}, - {file = "websockets-12.0-cp310-cp310-win32.whl", hash = "sha256:befe90632d66caaf72e8b2ed4d7f02b348913813c8b0a32fae1cc5fe3730902f"}, - {file = "websockets-12.0-cp310-cp310-win_amd64.whl", hash = "sha256:363f57ca8bc8576195d0540c648aa58ac18cf85b76ad5202b9f976918f4219cf"}, - {file = "websockets-12.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5d873c7de42dea355d73f170be0f23788cf3fa9f7bed718fd2830eefedce01b4"}, - {file = "websockets-12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f61726cae9f65b872502ff3c1496abc93ffbe31b278455c418492016e2afc8f"}, - {file = "websockets-12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ed2fcf7a07334c77fc8a230755c2209223a7cc44fc27597729b8ef5425aa61a3"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e332c210b14b57904869ca9f9bf4ca32f5427a03eeb625da9b616c85a3a506c"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5693ef74233122f8ebab026817b1b37fe25c411ecfca084b29bc7d6efc548f45"}, - {file = "websockets-12.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e9e7db18b4539a29cc5ad8c8b252738a30e2b13f033c2d6e9d0549b45841c04"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6e2df67b8014767d0f785baa98393725739287684b9f8d8a1001eb2839031447"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bea88d71630c5900690fcb03161ab18f8f244805c59e2e0dc4ffadae0a7ee0ca"}, - {file = "websockets-12.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:dff6cdf35e31d1315790149fee351f9e52978130cef6c87c4b6c9b3baf78bc53"}, - {file = "websockets-12.0-cp311-cp311-win32.whl", hash = "sha256:3e3aa8c468af01d70332a382350ee95f6986db479ce7af14d5e81ec52aa2b402"}, - {file = "websockets-12.0-cp311-cp311-win_amd64.whl", hash = "sha256:25eb766c8ad27da0f79420b2af4b85d29914ba0edf69f547cc4f06ca6f1d403b"}, - {file = "websockets-12.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0e6e2711d5a8e6e482cacb927a49a3d432345dfe7dea8ace7b5790df5932e4df"}, - {file = "websockets-12.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:dbcf72a37f0b3316e993e13ecf32f10c0e1259c28ffd0a85cee26e8549595fbc"}, - {file = "websockets-12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:12743ab88ab2af1d17dd4acb4645677cb7063ef4db93abffbf164218a5d54c6b"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b645f491f3c48d3f8a00d1fce07445fab7347fec54a3e65f0725d730d5b99cb"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9893d1aa45a7f8b3bc4510f6ccf8db8c3b62120917af15e3de247f0780294b92"}, - {file = "websockets-12.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f38a7b376117ef7aff996e737583172bdf535932c9ca021746573bce40165ed"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f764ba54e33daf20e167915edc443b6f88956f37fb606449b4a5b10ba42235a5"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:1e4b3f8ea6a9cfa8be8484c9221ec0257508e3a1ec43c36acdefb2a9c3b00aa2"}, - {file = "websockets-12.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9fdf06fd06c32205a07e47328ab49c40fc1407cdec801d698a7c41167ea45113"}, - {file = "websockets-12.0-cp312-cp312-win32.whl", hash = "sha256:baa386875b70cbd81798fa9f71be689c1bf484f65fd6fb08d051a0ee4e79924d"}, - {file = "websockets-12.0-cp312-cp312-win_amd64.whl", hash = "sha256:ae0a5da8f35a5be197f328d4727dbcfafa53d1824fac3d96cdd3a642fe09394f"}, - {file = "websockets-12.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5f6ffe2c6598f7f7207eef9a1228b6f5c818f9f4d53ee920aacd35cec8110438"}, - {file = "websockets-12.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9edf3fc590cc2ec20dc9d7a45108b5bbaf21c0d89f9fd3fd1685e223771dc0b2"}, - {file = "websockets-12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8572132c7be52632201a35f5e08348137f658e5ffd21f51f94572ca6c05ea81d"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:604428d1b87edbf02b233e2c207d7d528460fa978f9e391bd8aaf9c8311de137"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1a9d160fd080c6285e202327aba140fc9a0d910b09e423afff4ae5cbbf1c7205"}, - {file = "websockets-12.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87b4aafed34653e465eb77b7c93ef058516cb5acf3eb21e42f33928616172def"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b2ee7288b85959797970114deae81ab41b731f19ebcd3bd499ae9ca0e3f1d2c8"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7fa3d25e81bfe6a89718e9791128398a50dec6d57faf23770787ff441d851967"}, - {file = "websockets-12.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:a571f035a47212288e3b3519944f6bf4ac7bc7553243e41eac50dd48552b6df7"}, - {file = "websockets-12.0-cp38-cp38-win32.whl", hash = "sha256:3c6cc1360c10c17463aadd29dd3af332d4a1adaa8796f6b0e9f9df1fdb0bad62"}, - {file = "websockets-12.0-cp38-cp38-win_amd64.whl", hash = "sha256:1bf386089178ea69d720f8db6199a0504a406209a0fc23e603b27b300fdd6892"}, - {file = "websockets-12.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:ab3d732ad50a4fbd04a4490ef08acd0517b6ae6b77eb967251f4c263011a990d"}, - {file = "websockets-12.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a1d9697f3337a89691e3bd8dc56dea45a6f6d975f92e7d5f773bc715c15dde28"}, - {file = "websockets-12.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:1df2fbd2c8a98d38a66f5238484405b8d1d16f929bb7a33ed73e4801222a6f53"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23509452b3bc38e3a057382c2e941d5ac2e01e251acce7adc74011d7d8de434c"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e5fc14ec6ea568200ea4ef46545073da81900a2b67b3e666f04adf53ad452ec"}, - {file = "websockets-12.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46e71dbbd12850224243f5d2aeec90f0aaa0f2dde5aeeb8fc8df21e04d99eff9"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b81f90dcc6c85a9b7f29873beb56c94c85d6f0dac2ea8b60d995bd18bf3e2aae"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:a02413bc474feda2849c59ed2dfb2cddb4cd3d2f03a2fedec51d6e959d9b608b"}, - {file = "websockets-12.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bbe6013f9f791944ed31ca08b077e26249309639313fff132bfbf3ba105673b9"}, - {file = "websockets-12.0-cp39-cp39-win32.whl", hash = "sha256:cbe83a6bbdf207ff0541de01e11904827540aa069293696dd528a6640bd6a5f6"}, - {file = "websockets-12.0-cp39-cp39-win_amd64.whl", hash = "sha256:fc4e7fa5414512b481a2483775a8e8be7803a35b30ca805afa4998a84f9fd9e8"}, - {file = "websockets-12.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:248d8e2446e13c1d4326e0a6a4e9629cb13a11195051a73acf414812700badbd"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f44069528d45a933997a6fef143030d8ca8042f0dfaad753e2906398290e2870"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4e37d36f0d19f0a4413d3e18c0d03d0c268ada2061868c1e6f5ab1a6d575077"}, - {file = "websockets-12.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d829f975fc2e527a3ef2f9c8f25e553eb7bc779c6665e8e1d52aa22800bb38b"}, - {file = "websockets-12.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2c71bd45a777433dd9113847af751aae36e448bc6b8c361a566cb043eda6ec30"}, - {file = "websockets-12.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:0bee75f400895aef54157b36ed6d3b308fcab62e5260703add87f44cee9c82a6"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:423fc1ed29f7512fceb727e2d2aecb952c46aa34895e9ed96071821309951123"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27a5e9964ef509016759f2ef3f2c1e13f403725a5e6a1775555994966a66e931"}, - {file = "websockets-12.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3181df4583c4d3994d31fb235dc681d2aaad744fbdbf94c4802485ececdecf2"}, - {file = "websockets-12.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:b067cb952ce8bf40115f6c19f478dc71c5e719b7fbaa511359795dfd9d1a6468"}, - {file = "websockets-12.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:00700340c6c7ab788f176d118775202aadea7602c5cc6be6ae127761c16d6b0b"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e469d01137942849cff40517c97a30a93ae79917752b34029f0ec72df6b46399"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffefa1374cd508d633646d51a8e9277763a9b78ae71324183693959cf94635a7"}, - {file = "websockets-12.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0cab91b3956dfa9f512147860783a1829a8d905ee218a9837c18f683239611"}, - {file = "websockets-12.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2cb388a5bfb56df4d9a406783b7f9dbefb888c09b71629351cc6b036e9259370"}, - {file = "websockets-12.0-py3-none-any.whl", hash = "sha256:dc284bbc8d7c78a6c69e0c7325ab46ee5e40bb4d50e494d8131a07ef47500e9e"}, - {file = "websockets-12.0.tar.gz", hash = "sha256:81df9cbcbb6c260de1e007e58c011bfebe2dafc8435107b0537f393dd38c8b1b"}, -] - -[[package]] -name = "wrapt" -version = "1.16.0" -description = "Module for decorators, wrappers and monkey patching." +name = "uvicorn" +version = "0.35.0" +description = "The lightning-fast ASGI server." optional = false -python-versions = ">=3.6" -files = [ - {file = "wrapt-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4"}, - {file = "wrapt-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e4fdb9275308292e880dcbeb12546df7f3e0f96c6b41197e0cf37d2826359020"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb2dee3874a500de01c93d5c71415fcaef1d858370d405824783e7a8ef5db440"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a88e6010048489cda82b1326889ec075a8c856c2e6a256072b28eaee3ccf487"}, - {file = "wrapt-1.16.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac83a914ebaf589b69f7d0a1277602ff494e21f4c2f743313414378f8f50a4cf"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:73aa7d98215d39b8455f103de64391cb79dfcad601701a3aa0dddacf74911d72"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:807cc8543a477ab7422f1120a217054f958a66ef7314f76dd9e77d3f02cdccd0"}, - {file = "wrapt-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:bf5703fdeb350e36885f2875d853ce13172ae281c56e509f4e6eca049bdfb136"}, - {file = "wrapt-1.16.0-cp310-cp310-win32.whl", hash = "sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d"}, - {file = "wrapt-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:decbfa2f618fa8ed81c95ee18a387ff973143c656ef800c9f24fb7e9c16054e2"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a5db485fe2de4403f13fafdc231b0dbae5eca4359232d2efc79025527375b09"}, - {file = "wrapt-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75ea7d0ee2a15733684badb16de6794894ed9c55aa5e9903260922f0482e687d"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a452f9ca3e3267cd4d0fcf2edd0d035b1934ac2bd7e0e57ac91ad6b95c0c6389"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:43aa59eadec7890d9958748db829df269f0368521ba6dc68cc172d5d03ed8060"}, - {file = "wrapt-1.16.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72554a23c78a8e7aa02abbd699d129eead8b147a23c56e08d08dfc29cfdddca1"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:d2efee35b4b0a347e0d99d28e884dfd82797852d62fcd7ebdeee26f3ceb72cf3"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:6dcfcffe73710be01d90cae08c3e548d90932d37b39ef83969ae135d36ef3956"}, - {file = "wrapt-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:eb6e651000a19c96f452c85132811d25e9264d836951022d6e81df2fff38337d"}, - {file = "wrapt-1.16.0-cp311-cp311-win32.whl", hash = "sha256:66027d667efe95cc4fa945af59f92c5a02c6f5bb6012bff9e60542c74c75c362"}, - {file = "wrapt-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:aefbc4cb0a54f91af643660a0a150ce2c090d3652cf4052a5397fb2de549cd89"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:5eb404d89131ec9b4f748fa5cfb5346802e5ee8836f57d516576e61f304f3b7b"}, - {file = "wrapt-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9090c9e676d5236a6948330e83cb89969f433b1943a558968f659ead07cb3b36"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94265b00870aa407bd0cbcfd536f17ecde43b94fb8d228560a1e9d3041462d73"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2058f813d4f2b5e3a9eb2eb3faf8f1d99b81c3e51aeda4b168406443e8ba809"}, - {file = "wrapt-1.16.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98b5e1f498a8ca1858a1cdbffb023bfd954da4e3fa2c0cb5853d40014557248b"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:49aac49dc4782cb04f58986e81ea0b4768e4ff197b57324dcbd7699c5dfb40b9"}, - {file = "wrapt-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:418abb18146475c310d7a6dc71143d6f7adec5b004ac9ce08dc7a34e2babdc5c"}, - {file = "wrapt-1.16.0-cp312-cp312-win32.whl", hash = "sha256:685f568fa5e627e93f3b52fda002c7ed2fa1800b50ce51f6ed1d572d8ab3e7fc"}, - {file = "wrapt-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:dcdba5c86e368442528f7060039eda390cc4091bfd1dca41e8046af7c910dda8"}, - {file = "wrapt-1.16.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:d462f28826f4657968ae51d2181a074dfe03c200d6131690b7d65d55b0f360f8"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a33a747400b94b6d6b8a165e4480264a64a78c8a4c734b62136062e9a248dd39"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3646eefa23daeba62643a58aac816945cadc0afaf21800a1421eeba5f6cfb9c"}, - {file = "wrapt-1.16.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebf019be5c09d400cf7b024aa52b1f3aeebeff51550d007e92c3c1c4afc2a40"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:1acd723ee2a8826f3d53910255643e33673e1d11db84ce5880675954183ec47e"}, - {file = "wrapt-1.16.0-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:bc57efac2da352a51cc4658878a68d2b1b67dbe9d33c36cb826ca449d80a8465"}, - {file = "wrapt-1.16.0-cp36-cp36m-win32.whl", hash = "sha256:da4813f751142436b075ed7aa012a8778aa43a99f7b36afe9b742d3ed8bdc95e"}, - {file = "wrapt-1.16.0-cp36-cp36m-win_amd64.whl", hash = "sha256:6f6eac2360f2d543cc875a0e5efd413b6cbd483cb3ad7ebf888884a6e0d2e966"}, - {file = "wrapt-1.16.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a0ea261ce52b5952bf669684a251a66df239ec6d441ccb59ec7afa882265d593"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bd2d7ff69a2cac767fbf7a2b206add2e9a210e57947dd7ce03e25d03d2de292"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9159485323798c8dc530a224bd3ffcf76659319ccc7bbd52e01e73bd0241a0c5"}, - {file = "wrapt-1.16.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a86373cf37cd7764f2201b76496aba58a52e76dedfaa698ef9e9688bfd9e41cf"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:73870c364c11f03ed072dda68ff7aea6d2a3a5c3fe250d917a429c7432e15228"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b935ae30c6e7400022b50f8d359c03ed233d45b725cfdd299462f41ee5ffba6f"}, - {file = "wrapt-1.16.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:db98ad84a55eb09b3c32a96c576476777e87c520a34e2519d3e59c44710c002c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win32.whl", hash = "sha256:9153ed35fc5e4fa3b2fe97bddaa7cbec0ed22412b85bcdaf54aeba92ea37428c"}, - {file = "wrapt-1.16.0-cp37-cp37m-win_amd64.whl", hash = "sha256:66dfbaa7cfa3eb707bbfcd46dab2bc6207b005cbc9caa2199bcbc81d95071a00"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1dd50a2696ff89f57bd8847647a1c363b687d3d796dc30d4dd4a9d1689a706f0"}, - {file = "wrapt-1.16.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:44a2754372e32ab315734c6c73b24351d06e77ffff6ae27d2ecf14cf3d229202"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e9723528b9f787dc59168369e42ae1c3b0d3fadb2f1a71de14531d321ee05b0"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dbed418ba5c3dce92619656802cc5355cb679e58d0d89b50f116e4a9d5a9603e"}, - {file = "wrapt-1.16.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:941988b89b4fd6b41c3f0bfb20e92bd23746579736b7343283297c4c8cbae68f"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6a42cd0cfa8ffc1915aef79cb4284f6383d8a3e9dcca70c445dcfdd639d51267"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ca9b6085e4f866bd584fb135a041bfc32cab916e69f714a7d1d397f8c4891ca"}, - {file = "wrapt-1.16.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d5e49454f19ef621089e204f862388d29e6e8d8b162efce05208913dde5b9ad6"}, - {file = "wrapt-1.16.0-cp38-cp38-win32.whl", hash = "sha256:c31f72b1b6624c9d863fc095da460802f43a7c6868c5dda140f51da24fd47d7b"}, - {file = "wrapt-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:490b0ee15c1a55be9c1bd8609b8cecd60e325f0575fc98f50058eae366e01f41"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9b201ae332c3637a42f02d1045e1d0cccfdc41f1f2f801dafbaa7e9b4797bfc2"}, - {file = "wrapt-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2076fad65c6736184e77d7d4729b63a6d1ae0b70da4868adeec40989858eb3fb"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5cd603b575ebceca7da5a3a251e69561bec509e0b46e4993e1cac402b7247b8"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b47cfad9e9bbbed2339081f4e346c93ecd7ab504299403320bf85f7f85c7d46c"}, - {file = "wrapt-1.16.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5f15814a33e42b04e3de432e573aa557f9f0f56458745c2074952f564c50e664"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db2e408d983b0e61e238cf579c09ef7020560441906ca990fe8412153e3b291f"}, - {file = "wrapt-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:edfad1d29c73f9b863ebe7082ae9321374ccb10879eeabc84ba3b69f2579d537"}, - {file = "wrapt-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed867c42c268f876097248e05b6117a65bcd1e63b779e916fe2e33cd6fd0d3c3"}, - {file = "wrapt-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:eb1b046be06b0fce7249f1d025cd359b4b80fc1c3e24ad9eca33e0dcdb2e4a35"}, - {file = "wrapt-1.16.0-py3-none-any.whl", hash = "sha256:6906c4100a8fcbf2fa735f6059214bb13b97f75b1a61777fcf6432121ef12ef1"}, - {file = "wrapt-1.16.0.tar.gz", hash = "sha256:5f370f952971e7d17c7d1ead40e49f32345a7f7a5373571ef44d800d06b1899d"}, -] - -[[package]] -name = "yarl" -version = "1.11.1" -description = "Yet another URL library" -optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "yarl-1.11.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:400cd42185f92de559d29eeb529e71d80dfbd2f45c36844914a4a34297ca6f00"}, - {file = "yarl-1.11.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8258c86f47e080a258993eed877d579c71da7bda26af86ce6c2d2d072c11320d"}, - {file = "yarl-1.11.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2164cd9725092761fed26f299e3f276bb4b537ca58e6ff6b252eae9631b5c96e"}, - {file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08ea567c16f140af8ddc7cb58e27e9138a1386e3e6e53982abaa6f2377b38cc"}, - {file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:768ecc550096b028754ea28bf90fde071c379c62c43afa574edc6f33ee5daaec"}, - {file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2909fa3a7d249ef64eeb2faa04b7957e34fefb6ec9966506312349ed8a7e77bf"}, - {file = "yarl-1.11.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01a8697ec24f17c349c4f655763c4db70eebc56a5f82995e5e26e837c6eb0e49"}, - {file = "yarl-1.11.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e286580b6511aac7c3268a78cdb861ec739d3e5a2a53b4809faef6b49778eaff"}, - {file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4179522dc0305c3fc9782549175c8e8849252fefeb077c92a73889ccbcd508ad"}, - {file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:27fcb271a41b746bd0e2a92182df507e1c204759f460ff784ca614e12dd85145"}, - {file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f61db3b7e870914dbd9434b560075e0366771eecbe6d2b5561f5bc7485f39efd"}, - {file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c92261eb2ad367629dc437536463dc934030c9e7caca861cc51990fe6c565f26"}, - {file = "yarl-1.11.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d95b52fbef190ca87d8c42f49e314eace4fc52070f3dfa5f87a6594b0c1c6e46"}, - {file = "yarl-1.11.1-cp310-cp310-win32.whl", hash = "sha256:489fa8bde4f1244ad6c5f6d11bb33e09cf0d1d0367edb197619c3e3fc06f3d91"}, - {file = "yarl-1.11.1-cp310-cp310-win_amd64.whl", hash = "sha256:476e20c433b356e16e9a141449f25161e6b69984fb4cdbd7cd4bd54c17844998"}, - {file = "yarl-1.11.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:946eedc12895873891aaceb39bceb484b4977f70373e0122da483f6c38faaa68"}, - {file = "yarl-1.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:21a7c12321436b066c11ec19c7e3cb9aec18884fe0d5b25d03d756a9e654edfe"}, - {file = "yarl-1.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c35f493b867912f6fda721a59cc7c4766d382040bdf1ddaeeaa7fa4d072f4675"}, - {file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25861303e0be76b60fddc1250ec5986c42f0a5c0c50ff57cc30b1be199c00e63"}, - {file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4b53f73077e839b3f89c992223f15b1d2ab314bdbdf502afdc7bb18e95eae27"}, - {file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:327c724b01b8641a1bf1ab3b232fb638706e50f76c0b5bf16051ab65c868fac5"}, - {file = "yarl-1.11.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4307d9a3417eea87715c9736d050c83e8c1904e9b7aada6ce61b46361b733d92"}, - {file = "yarl-1.11.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a28bed68ab8fb7e380775f0029a079f08a17799cb3387a65d14ace16c12e2b"}, - {file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:067b961853c8e62725ff2893226fef3d0da060656a9827f3f520fb1d19b2b68a"}, - {file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8215f6f21394d1f46e222abeb06316e77ef328d628f593502d8fc2a9117bde83"}, - {file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:498442e3af2a860a663baa14fbf23fb04b0dd758039c0e7c8f91cb9279799bff"}, - {file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:69721b8effdb588cb055cc22f7c5105ca6fdaa5aeb3ea09021d517882c4a904c"}, - {file = "yarl-1.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e969fa4c1e0b1a391f3fcbcb9ec31e84440253325b534519be0d28f4b6b533e"}, - {file = "yarl-1.11.1-cp311-cp311-win32.whl", hash = "sha256:7d51324a04fc4b0e097ff8a153e9276c2593106a811704025bbc1d6916f45ca6"}, - {file = "yarl-1.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:15061ce6584ece023457fb8b7a7a69ec40bf7114d781a8c4f5dcd68e28b5c53b"}, - {file = "yarl-1.11.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a4264515f9117be204935cd230fb2a052dd3792789cc94c101c535d349b3dab0"}, - {file = "yarl-1.11.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f41fa79114a1d2eddb5eea7b912d6160508f57440bd302ce96eaa384914cd265"}, - {file = "yarl-1.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:02da8759b47d964f9173c8675710720b468aa1c1693be0c9c64abb9d8d9a4867"}, - {file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9361628f28f48dcf8b2f528420d4d68102f593f9c2e592bfc842f5fb337e44fd"}, - {file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b91044952da03b6f95fdba398d7993dd983b64d3c31c358a4c89e3c19b6f7aef"}, - {file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74db2ef03b442276d25951749a803ddb6e270d02dda1d1c556f6ae595a0d76a8"}, - {file = "yarl-1.11.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e975a2211952a8a083d1b9d9ba26472981ae338e720b419eb50535de3c02870"}, - {file = "yarl-1.11.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aef97ba1dd2138112890ef848e17d8526fe80b21f743b4ee65947ea184f07a2"}, - {file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7915ea49b0c113641dc4d9338efa9bd66b6a9a485ffe75b9907e8573ca94b84"}, - {file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:504cf0d4c5e4579a51261d6091267f9fd997ef58558c4ffa7a3e1460bd2336fa"}, - {file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3de5292f9f0ee285e6bd168b2a77b2a00d74cbcfa420ed078456d3023d2f6dff"}, - {file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a34e1e30f1774fa35d37202bbeae62423e9a79d78d0874e5556a593479fdf239"}, - {file = "yarl-1.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:66b63c504d2ca43bf7221a1f72fbe981ff56ecb39004c70a94485d13e37ebf45"}, - {file = "yarl-1.11.1-cp312-cp312-win32.whl", hash = "sha256:a28b70c9e2213de425d9cba5ab2e7f7a1c8ca23a99c4b5159bf77b9c31251447"}, - {file = "yarl-1.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:17b5a386d0d36fb828e2fb3ef08c8829c1ebf977eef88e5367d1c8c94b454639"}, - {file = "yarl-1.11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:1fa2e7a406fbd45b61b4433e3aa254a2c3e14c4b3186f6e952d08a730807fa0c"}, - {file = "yarl-1.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:750f656832d7d3cb0c76be137ee79405cc17e792f31e0a01eee390e383b2936e"}, - {file = "yarl-1.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0b8486f322d8f6a38539136a22c55f94d269addb24db5cb6f61adc61eabc9d93"}, - {file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fce4da3703ee6048ad4138fe74619c50874afe98b1ad87b2698ef95bf92c96d"}, - {file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed653638ef669e0efc6fe2acb792275cb419bf9cb5c5049399f3556995f23c7"}, - {file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18ac56c9dd70941ecad42b5a906820824ca72ff84ad6fa18db33c2537ae2e089"}, - {file = "yarl-1.11.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:688654f8507464745ab563b041d1fb7dab5d9912ca6b06e61d1c4708366832f5"}, - {file = "yarl-1.11.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4973eac1e2ff63cf187073cd4e1f1148dcd119314ab79b88e1b3fad74a18c9d5"}, - {file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:964a428132227edff96d6f3cf261573cb0f1a60c9a764ce28cda9525f18f7786"}, - {file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6d23754b9939cbab02c63434776df1170e43b09c6a517585c7ce2b3d449b7318"}, - {file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c2dc4250fe94d8cd864d66018f8344d4af50e3758e9d725e94fecfa27588ff82"}, - {file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09696438cb43ea6f9492ef237761b043f9179f455f405279e609f2bc9100212a"}, - {file = "yarl-1.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:999bfee0a5b7385a0af5ffb606393509cfde70ecca4f01c36985be6d33e336da"}, - {file = "yarl-1.11.1-cp313-cp313-win32.whl", hash = "sha256:ce928c9c6409c79e10f39604a7e214b3cb69552952fbda8d836c052832e6a979"}, - {file = "yarl-1.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:501c503eed2bb306638ccb60c174f856cc3246c861829ff40eaa80e2f0330367"}, - {file = "yarl-1.11.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dae7bd0daeb33aa3e79e72877d3d51052e8b19c9025ecf0374f542ea8ec120e4"}, - {file = "yarl-1.11.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3ff6b1617aa39279fe18a76c8d165469c48b159931d9b48239065767ee455b2b"}, - {file = "yarl-1.11.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:3257978c870728a52dcce8c2902bf01f6c53b65094b457bf87b2644ee6238ddc"}, - {file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f351fa31234699d6084ff98283cb1e852270fe9e250a3b3bf7804eb493bd937"}, - {file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8aef1b64da41d18026632d99a06b3fefe1d08e85dd81d849fa7c96301ed22f1b"}, - {file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7175a87ab8f7fbde37160a15e58e138ba3b2b0e05492d7351314a250d61b1591"}, - {file = "yarl-1.11.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba444bdd4caa2a94456ef67a2f383710928820dd0117aae6650a4d17029fa25e"}, - {file = "yarl-1.11.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0ea9682124fc062e3d931c6911934a678cb28453f957ddccf51f568c2f2b5e05"}, - {file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8418c053aeb236b20b0ab8fa6bacfc2feaaf7d4683dd96528610989c99723d5f"}, - {file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:61a5f2c14d0a1adfdd82258f756b23a550c13ba4c86c84106be4c111a3a4e413"}, - {file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f3a6d90cab0bdf07df8f176eae3a07127daafcf7457b997b2bf46776da2c7eb7"}, - {file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:077da604852be488c9a05a524068cdae1e972b7dc02438161c32420fb4ec5e14"}, - {file = "yarl-1.11.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:15439f3c5c72686b6c3ff235279630d08936ace67d0fe5c8d5bbc3ef06f5a420"}, - {file = "yarl-1.11.1-cp38-cp38-win32.whl", hash = "sha256:238a21849dd7554cb4d25a14ffbfa0ef380bb7ba201f45b144a14454a72ffa5a"}, - {file = "yarl-1.11.1-cp38-cp38-win_amd64.whl", hash = "sha256:67459cf8cf31da0e2cbdb4b040507e535d25cfbb1604ca76396a3a66b8ba37a6"}, - {file = "yarl-1.11.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:884eab2ce97cbaf89f264372eae58388862c33c4f551c15680dd80f53c89a269"}, - {file = "yarl-1.11.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8a336eaa7ee7e87cdece3cedb395c9657d227bfceb6781295cf56abcd3386a26"}, - {file = "yarl-1.11.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:87f020d010ba80a247c4abc335fc13421037800ca20b42af5ae40e5fd75e7909"}, - {file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:637c7ddb585a62d4469f843dac221f23eec3cbad31693b23abbc2c366ad41ff4"}, - {file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:48dfd117ab93f0129084577a07287376cc69c08138694396f305636e229caa1a"}, - {file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75e0ae31fb5ccab6eda09ba1494e87eb226dcbd2372dae96b87800e1dcc98804"}, - {file = "yarl-1.11.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f46f81501160c28d0c0b7333b4f7be8983dbbc161983b6fb814024d1b4952f79"}, - {file = "yarl-1.11.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:04293941646647b3bfb1719d1d11ff1028e9c30199509a844da3c0f5919dc520"}, - {file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:250e888fa62d73e721f3041e3a9abf427788a1934b426b45e1b92f62c1f68366"}, - {file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e8f63904df26d1a66aabc141bfd258bf738b9bc7bc6bdef22713b4f5ef789a4c"}, - {file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:aac44097d838dda26526cffb63bdd8737a2dbdf5f2c68efb72ad83aec6673c7e"}, - {file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:267b24f891e74eccbdff42241c5fb4f974de2d6271dcc7d7e0c9ae1079a560d9"}, - {file = "yarl-1.11.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:6907daa4b9d7a688063ed098c472f96e8181733c525e03e866fb5db480a424df"}, - {file = "yarl-1.11.1-cp39-cp39-win32.whl", hash = "sha256:14438dfc5015661f75f85bc5adad0743678eefee266ff0c9a8e32969d5d69f74"}, - {file = "yarl-1.11.1-cp39-cp39-win_amd64.whl", hash = "sha256:94d0caaa912bfcdc702a4204cd5e2bb01eb917fc4f5ea2315aa23962549561b0"}, - {file = "yarl-1.11.1-py3-none-any.whl", hash = "sha256:72bf26f66456baa0584eff63e44545c9f0eaed9b73cb6601b647c91f14c11f38"}, - {file = "yarl-1.11.1.tar.gz", hash = "sha256:1bb2d9e212fb7449b8fb73bc461b51eaa17cc8430b4a87d87be7b25052d92f53"}, + {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, + {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, ] [package.dependencies] -idna = ">=2.0" -multidict = ">=4.0" +click = ">=7.0" +h11 = ">=0.8" +typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "websockets" +version = "15.0.1" +description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, + {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, + {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, + {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, + {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, + {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, + {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, + {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, + {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, + {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, + {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, + {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, + {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, +] [[package]] name = "zipp" -version = "3.20.1" +version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "zipp-3.20.1-py3-none-any.whl", hash = "sha256:9960cd8967c8f85a56f920d5d507274e74f9ff813a0ab8889a5b5be2daf44064"}, - {file = "zipp-3.20.1.tar.gz", hash = "sha256:c22b14cc4763c5a5b04134207736c107db42e9d3ef2d9779d465f5f1bcba572b"}, + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] [metadata] -lock-version = "2.0" +lock-version = "2.1" python-versions = ">=3.10,<4.0" -content-hash = "13a36d3be675cab4a3eb2e6a62a1b08df779bded4c7b9164d8be300dc08748d0" +content-hash = "4cc687aabe5865665fb8c4ccc0ea7e0af80b41e401ca37919f57efa6e0b5be00" diff --git a/autogpt_platform/autogpt_libs/pyproject.toml b/autogpt_platform/autogpt_libs/pyproject.toml index 88e75f0bef6c..4cc29e34a762 100644 --- a/autogpt_platform/autogpt_libs/pyproject.toml +++ b/autogpt_platform/autogpt_libs/pyproject.toml @@ -1,27 +1,29 @@ [tool.poetry] name = "autogpt-libs" version = "0.2.0" -description = "Shared libraries across NextGen AutoGPT" -authors = ["Aarushi "] +description = "Shared libraries across AutoGPT Platform" +authors = ["AutoGPT team "] readme = "README.md" packages = [{ include = "autogpt_libs" }] [tool.poetry.dependencies] +python = ">=3.10,<4.0" colorama = "^0.4.6" expiringdict = "^1.2.2" -google-cloud-logging = "^3.11.3" -pydantic = "^2.10.3" -pydantic-settings = "^2.7.0" +fastapi = "^0.116.1" +google-cloud-logging = "^3.12.1" +launchdarkly-server-sdk = "^9.12.0" +pydantic = "^2.11.7" +pydantic-settings = "^2.10.1" pyjwt = "^2.10.1" -pytest-asyncio = "^0.25.0" -pytest-mock = "^3.14.0" -python = ">=3.10,<4.0" -python-dotenv = "^1.0.1" -supabase = "^2.10.0" +pytest-asyncio = "^1.1.0" +pytest-mock = "^3.14.1" +redis = "^6.2.0" +supabase = "^2.16.0" +uvicorn = "^0.35.0" [tool.poetry.group.dev.dependencies] -redis = "^5.2.1" -ruff = "^0.8.3" +ruff = "^0.12.9" [build-system] requires = ["poetry-core"] diff --git a/autogpt_platform/backend/.dockerignore b/autogpt_platform/backend/.dockerignore new file mode 100644 index 000000000000..e66c25e657c8 --- /dev/null +++ b/autogpt_platform/backend/.dockerignore @@ -0,0 +1,52 @@ +# Development and testing files +**/__pycache__ +**/*.pyc +**/*.pyo +**/*.pyd +**/.Python +**/env/ +**/venv/ +**/.venv/ +**/pip-log.txt +**/.pytest_cache/ +**/test-results/ +**/snapshots/ +**/test/ + +# IDE and editor files +**/.vscode/ +**/.idea/ +**/*.swp +**/*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Logs +**/*.log +**/logs/ + +# Git +.git/ +.gitignore + +# Documentation +**/*.md +!README.md + +# Local development files +.env +.env.local +**/.env.test + +# Build artifacts +**/dist/ +**/build/ +**/target/ + +# Docker files (avoid recursion) +Dockerfile* +docker-compose* +.dockerignore \ No newline at end of file diff --git a/autogpt_platform/backend/.env.default b/autogpt_platform/backend/.env.default new file mode 100644 index 000000000000..bcec6bf0a34d --- /dev/null +++ b/autogpt_platform/backend/.env.default @@ -0,0 +1,178 @@ +# Backend Configuration +# This file contains environment variables that MUST be set for the AutoGPT platform +# Variables with working defaults in settings.py are not included here + +## ===== REQUIRED DATABASE CONFIGURATION ===== ## +# PostgreSQL Database Connection +DB_USER=postgres +DB_PASS=your-super-secret-and-long-postgres-password +DB_NAME=postgres +DB_PORT=5432 +DB_HOST=localhost +DB_CONNECTION_LIMIT=12 +DB_CONNECT_TIMEOUT=60 +DB_POOL_TIMEOUT=300 +DB_SCHEMA=platform +DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=${DB_SCHEMA}&connect_timeout=${DB_CONNECT_TIMEOUT}" +DIRECT_URL="postgresql://${DB_USER}:${DB_PASS}@${DB_HOST}:${DB_PORT}/${DB_NAME}?schema=${DB_SCHEMA}&connect_timeout=${DB_CONNECT_TIMEOUT}" +PRISMA_SCHEMA="postgres/schema.prisma" +ENABLE_AUTH=true + +## ===== REQUIRED SERVICE CREDENTIALS ===== ## +# Redis Configuration +REDIS_HOST=localhost +REDIS_PORT=6379 +REDIS_PASSWORD=password + +# RabbitMQ Credentials +RABBITMQ_DEFAULT_USER=rabbitmq_user_default +RABBITMQ_DEFAULT_PASS=k0VMxyIJF9S35f3x2uaw5IWAl6Y536O7 + +# Supabase Authentication +SUPABASE_URL=http://localhost:8000 +SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q +SUPABASE_JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long + +## ===== REQUIRED SECURITY KEYS ===== ## +# Generate using: from cryptography.fernet import Fernet;Fernet.generate_key().decode() +ENCRYPTION_KEY=dvziYgz0KSK8FENhju0ZYi8-fRTfAdlz6YLhdB_jhNw= +UNSUBSCRIBE_SECRET_KEY=HlP8ivStJjmbf6NKi78m_3FnOogut0t5ckzjsIqeaio= + +## ===== IMPORTANT OPTIONAL CONFIGURATION ===== ## +# Platform URLs (set these for webhooks and OAuth to work) +PLATFORM_BASE_URL=http://localhost:8000 +FRONTEND_BASE_URL=http://localhost:3000 + +# Media Storage (required for marketplace and library functionality) +MEDIA_GCS_BUCKET_NAME= + +## ===== API KEYS AND OAUTH CREDENTIALS ===== ## +# All API keys below are optional - only add what you need + +# AI/LLM Services +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GROQ_API_KEY= +LLAMA_API_KEY= +AIML_API_KEY= +V0_API_KEY= +OPEN_ROUTER_API_KEY= +NVIDIA_API_KEY= + +# OAuth Credentials +# For the OAuth callback URL, use /auth/integrations/oauth_callback, +# e.g. http://localhost:3000/auth/integrations/oauth_callback + +# GitHub OAuth App server credentials - https://github.com/settings/developers +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Google OAuth App server credentials - https://console.cloud.google.com/apis/credentials, and enable gmail api and set scopes +# https://console.cloud.google.com/apis/credentials/consent ?project= +# You'll need to add/enable the following scopes (minimum): +# https://console.developers.google.com/apis/api/gmail.googleapis.com/overview ?project= +# https://console.cloud.google.com/apis/library/sheets.googleapis.com/ ?project= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# Twitter (X) OAuth 2.0 with PKCE Configuration +# 1. Create a Twitter Developer Account: +# - Visit https://developer.x.com/en and sign up +# 2. Set up your application: +# - Navigate to Developer Portal > Projects > Create Project +# - Add a new app to your project +# 3. Configure app settings: +# - App Permissions: Read + Write + Direct Messages +# - App Type: Web App, Automated App or Bot +# - OAuth 2.0 Callback URL: http://localhost:3000/auth/integrations/oauth_callback +# - Save your Client ID and Client Secret below +TWITTER_CLIENT_ID= +TWITTER_CLIENT_SECRET= + +# Linear App +# Make a new workspace for your OAuth APP -- trust me +# https://linear.app/settings/api/applications/new +# Callback URL: http://localhost:3000/auth/integrations/oauth_callback +LINEAR_CLIENT_ID= +LINEAR_CLIENT_SECRET= + +# To obtain Todoist API credentials: +# 1. Create a Todoist account at todoist.com +# 2. Visit the Developer Console: https://developer.todoist.com/appconsole.html +# 3. Click "Create new app" +# 4. Once created, copy your Client ID and Client Secret below +TODOIST_CLIENT_ID= +TODOIST_CLIENT_SECRET= + +NOTION_CLIENT_ID= +NOTION_CLIENT_SECRET= + +# Discord OAuth App credentials +# 1. Go to https://discord.com/developers/applications +# 2. Create a new application +# 3. Go to OAuth2 section and add redirect URI: http://localhost:3000/auth/integrations/oauth_callback +# 4. Copy Client ID and Client Secret below +DISCORD_CLIENT_ID= +DISCORD_CLIENT_SECRET= + +REDDIT_CLIENT_ID= +REDDIT_CLIENT_SECRET= + +# Payment Processing +STRIPE_API_KEY= +STRIPE_WEBHOOK_SECRET= + +# Email Service (for sending notifications and confirmations) +POSTMARK_SERVER_API_TOKEN= +POSTMARK_SENDER_EMAIL=invalid@invalid.com +POSTMARK_WEBHOOK_TOKEN= + +# Error Tracking +SENTRY_DSN= + +# Cloudflare Turnstile (CAPTCHA) Configuration +# Get these from the Cloudflare Turnstile dashboard: https://dash.cloudflare.com/?to=/:account/turnstile +# This is the backend secret key +TURNSTILE_SECRET_KEY= +# This is the verify URL +TURNSTILE_VERIFY_URL=https://challenges.cloudflare.com/turnstile/v0/siteverify + +# Feature Flags +LAUNCH_DARKLY_SDK_KEY= + +# Content Generation & Media +DID_API_KEY= +FAL_API_KEY= +IDEOGRAM_API_KEY= +REPLICATE_API_KEY= +REVID_API_KEY= +SCREENSHOTONE_API_KEY= +UNREAL_SPEECH_API_KEY= + +# Data & Search Services +E2B_API_KEY= +EXA_API_KEY= +JINA_API_KEY= +MEM0_API_KEY= +OPENWEATHERMAP_API_KEY= +GOOGLE_MAPS_API_KEY= + +# Communication Services +DISCORD_BOT_TOKEN= +MEDIUM_API_KEY= +MEDIUM_AUTHOR_ID= +SMTP_SERVER= +SMTP_PORT= +SMTP_USERNAME= +SMTP_PASSWORD= + +# Business & Marketing Tools +APOLLO_API_KEY= +ENRICHLAYER_API_KEY= +AYRSHARE_API_KEY= +AYRSHARE_JWT_KEY= +SMARTLEAD_API_KEY= +ZEROBOUNCE_API_KEY= + +# Other Services +AUTOMOD_API_KEY= \ No newline at end of file diff --git a/autogpt_platform/backend/.env.example b/autogpt_platform/backend/.env.example deleted file mode 100644 index 0dd10e838501..000000000000 --- a/autogpt_platform/backend/.env.example +++ /dev/null @@ -1,114 +0,0 @@ -DB_USER=postgres -DB_PASS=your-super-secret-and-long-postgres-password -DB_NAME=postgres -DB_PORT=5432 -DATABASE_URL="postgresql://${DB_USER}:${DB_PASS}@localhost:${DB_PORT}/${DB_NAME}?connect_timeout=60&schema=platform" -PRISMA_SCHEMA="postgres/schema.prisma" - -BACKEND_CORS_ALLOW_ORIGINS=["http://localhost:3000"] - -# generate using `from cryptography.fernet import Fernet;Fernet.generate_key().decode()` -ENCRYPTION_KEY='dvziYgz0KSK8FENhju0ZYi8-fRTfAdlz6YLhdB_jhNw=' - -REDIS_HOST=localhost -REDIS_PORT=6379 -REDIS_PASSWORD=password - -ENABLE_CREDIT=false -# What environment things should be logged under: local dev or prod -APP_ENV=local -# What environment to behave as: "local" or "cloud" -BEHAVE_AS=local -PYRO_HOST=localhost -SENTRY_DSN= - -## User auth with Supabase is required for any of the 3rd party integrations with auth to work. -ENABLE_AUTH=true -SUPABASE_URL=http://localhost:8000 -SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q -SUPABASE_JWT_SECRET=your-super-secret-jwt-token-with-at-least-32-characters-long - -## For local development, you may need to set FRONTEND_BASE_URL for the OAuth flow -## for integrations to work. Defaults to the value of PLATFORM_BASE_URL if not set. -# FRONTEND_BASE_URL=http://localhost:3000 - -## PLATFORM_BASE_URL must be set to a *publicly accessible* URL pointing to your backend -## to use the platform's webhook-related functionality. -## If you are developing locally, you can use something like ngrok to get a publc URL -## and tunnel it to your locally running backend. -PLATFORM_BASE_URL=https://your-public-url-here - -## == INTEGRATION CREDENTIALS == ## -# Each set of server side credentials is required for the corresponding 3rd party -# integration to work. - -# For the OAuth callback URL, use /auth/integrations/oauth_callback, -# e.g. http://localhost:3000/auth/integrations/oauth_callback - -# GitHub OAuth App server credentials - https://github.com/settings/developers -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= - -# Google OAuth App server credentials - https://console.cloud.google.com/apis/credentials, and enable gmail api and set scopes -# https://console.cloud.google.com/apis/credentials/consent ?project= - -# You'll need to add/enable the following scopes (minimum): -# https://console.developers.google.com/apis/api/gmail.googleapis.com/overview ?project= -# https://console.cloud.google.com/apis/library/sheets.googleapis.com/ ?project= -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= - -## ===== OPTIONAL API KEYS ===== ## - -# LLM -OPENAI_API_KEY= -ANTHROPIC_API_KEY= -GROQ_API_KEY= -OPEN_ROUTER_API_KEY= - -# Reddit -REDDIT_CLIENT_ID= -REDDIT_CLIENT_SECRET= -REDDIT_USERNAME= -REDDIT_PASSWORD= - -# Discord -DISCORD_BOT_TOKEN= - -# SMTP/Email -SMTP_SERVER= -SMTP_PORT= -SMTP_USERNAME= -SMTP_PASSWORD= - -# D-ID -DID_API_KEY= - -# Open Weather Map -OPENWEATHERMAP_API_KEY= - -# SMTP -SMTP_SERVER= -SMTP_PORT= -SMTP_USERNAME= -SMTP_PASSWORD= - -# Medium -MEDIUM_API_KEY= -MEDIUM_AUTHOR_ID= - -# Google Maps -GOOGLE_MAPS_API_KEY= - -# Replicate -REPLICATE_API_KEY= - -# Ideogram -IDEOGRAM_API_KEY= - -# Logging Configuration -LOG_LEVEL=INFO -ENABLE_CLOUD_LOGGING=false -ENABLE_FILE_LOGGING=false -# Use to manually set the log directory -# LOG_DIR=./logs diff --git a/autogpt_platform/backend/.gitignore b/autogpt_platform/backend/.gitignore index 1ce7f628ee1b..197d29072b1b 100644 --- a/autogpt_platform/backend/.gitignore +++ b/autogpt_platform/backend/.gitignore @@ -1,3 +1,4 @@ +.env database.db database.db-journal dev.db diff --git a/autogpt_platform/backend/Dockerfile b/autogpt_platform/backend/Dockerfile index 22b9d9bb0bd0..103812118775 100644 --- a/autogpt_platform/backend/Dockerfile +++ b/autogpt_platform/backend/Dockerfile @@ -1,68 +1,69 @@ -FROM python:3.11.10-slim-bookworm AS builder +FROM debian:13-slim AS builder # Set environment variables -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONUNBUFFERED 1 +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV DEBIAN_FRONTEND=noninteractive WORKDIR /app RUN echo 'Acquire::http::Pipeline-Depth 0;\nAcquire::http::No-Cache true;\nAcquire::BrokenProxy true;\n' > /etc/apt/apt.conf.d/99fixbadproxy -RUN apt-get update --allow-releaseinfo-change --fix-missing +# Update package list and install Python and build dependencies +RUN apt-get update --allow-releaseinfo-change --fix-missing \ + && apt-get install -y \ + python3.13 \ + python3.13-dev \ + python3.13-venv \ + python3-pip \ + build-essential \ + libpq5 \ + libz-dev \ + libssl-dev \ + postgresql-client -# Install build dependencies -RUN apt-get install -y build-essential -RUN apt-get install -y libpq5 -RUN apt-get install -y libz-dev -RUN apt-get install -y libssl-dev -RUN apt-get install -y postgresql-client - -ENV POETRY_VERSION=1.8.3 ENV POETRY_HOME=/opt/poetry ENV POETRY_NO_INTERACTION=1 -ENV POETRY_VIRTUALENVS_CREATE=false +ENV POETRY_VIRTUALENVS_CREATE=true +ENV POETRY_VIRTUALENVS_IN_PROJECT=true ENV PATH=/opt/poetry/bin:$PATH - -# Upgrade pip and setuptools to fix security vulnerabilities -RUN pip3 install --upgrade pip setuptools -RUN pip3 install poetry +RUN pip3 install poetry --break-system-packages # Copy and install dependencies COPY autogpt_platform/autogpt_libs /app/autogpt_platform/autogpt_libs COPY autogpt_platform/backend/poetry.lock autogpt_platform/backend/pyproject.toml /app/autogpt_platform/backend/ WORKDIR /app/autogpt_platform/backend -RUN poetry config virtualenvs.create false \ - && poetry install --no-interaction --no-ansi +RUN poetry install --no-ansi --no-root # Generate Prisma client COPY autogpt_platform/backend/schema.prisma ./ -RUN poetry config virtualenvs.create false \ - && poetry run prisma generate +RUN poetry run prisma generate -FROM python:3.11.10-slim-bookworm AS server_dependencies +FROM debian:13-slim AS server_dependencies WORKDIR /app -ENV POETRY_VERSION=1.8.3 -ENV POETRY_HOME=/opt/poetry -ENV POETRY_NO_INTERACTION=1 -ENV POETRY_VIRTUALENVS_CREATE=false +ENV POETRY_HOME=/opt/poetry \ + POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_CREATE=true \ + POETRY_VIRTUALENVS_IN_PROJECT=true \ + DEBIAN_FRONTEND=noninteractive ENV PATH=/opt/poetry/bin:$PATH - -# Upgrade pip and setuptools to fix security vulnerabilities -RUN pip3 install --upgrade pip setuptools +# Install Python without upgrading system-managed packages +RUN apt-get update && apt-get install -y \ + python3.13 \ + python3-pip # Copy only necessary files from builder COPY --from=builder /app /app -COPY --from=builder /usr/local/lib/python3.11 /usr/local/lib/python3.11 -COPY --from=builder /usr/local/bin /usr/local/bin +COPY --from=builder /usr/local/lib/python3* /usr/local/lib/python3* +COPY --from=builder /usr/local/bin/poetry /usr/local/bin/poetry # Copy Prisma binaries COPY --from=builder /root/.cache/prisma-python/binaries /root/.cache/prisma-python/binaries - -ENV PATH="/app/.venv/bin:$PATH" +ENV PATH="/app/autogpt_platform/backend/.venv/bin:$PATH" RUN mkdir -p /app/autogpt_platform/autogpt_libs RUN mkdir -p /app/autogpt_platform/backend @@ -73,11 +74,17 @@ COPY autogpt_platform/backend/poetry.lock autogpt_platform/backend/pyproject.tom WORKDIR /app/autogpt_platform/backend +FROM server_dependencies AS migrate + +# Migration stage only needs schema and migrations - much lighter than full backend +COPY autogpt_platform/backend/schema.prisma /app/autogpt_platform/backend/ +COPY autogpt_platform/backend/migrations /app/autogpt_platform/backend/migrations + FROM server_dependencies AS server COPY autogpt_platform/backend /app/autogpt_platform/backend +RUN poetry install --no-ansi --only-root -ENV DATABASE_URL="" ENV PORT=8000 CMD ["poetry", "run", "rest"] diff --git a/autogpt_platform/backend/README.advanced.md b/autogpt_platform/backend/README.advanced.md index 09e0f90fcc25..5d5333e1340d 100644 --- a/autogpt_platform/backend/README.advanced.md +++ b/autogpt_platform/backend/README.advanced.md @@ -1,75 +1 @@ -# AutoGPT Agent Server Advanced set up - -This guide walks you through a dockerized set up, with an external DB (postgres) - -## Setup - -We use the Poetry to manage the dependencies. To set up the project, follow these steps inside this directory: - -0. Install Poetry - ```sh - pip install poetry - ``` - -1. Configure Poetry to use .venv in your project directory - ```sh - poetry config virtualenvs.in-project true - ``` - -2. Enter the poetry shell - - ```sh - poetry shell - ``` - -3. Install dependencies - - ```sh - poetry install - ``` - -4. Copy .env.example to .env - - ```sh - cp .env.example .env - ``` - -5. Generate the Prisma client - - ```sh - poetry run prisma generate - ``` - - - > In case Prisma generates the client for the global Python installation instead of the virtual environment, the current mitigation is to just uninstall the global Prisma package: - > - > ```sh - > pip uninstall prisma - > ``` - > - > Then run the generation again. The path *should* look something like this: - > `/pypoetry/virtualenvs/backend-TQIRSwR6-py3.12/bin/prisma` - -6. Run the postgres database from the /rnd folder - - ```sh - cd autogpt_platform/ - docker compose up -d - ``` - -7. Run the migrations (from the backend folder) - - ```sh - cd ../backend - prisma migrate deploy - ``` - -## Running The Server - -### Starting the server directly - -Run the following command: - -```sh -poetry run app -``` +[Advanced Setup (Dev Branch)](https://dev-docs.agpt.co/platform/advanced_setup/#autogpt_agent_server_advanced_set_up) \ No newline at end of file diff --git a/autogpt_platform/backend/README.md b/autogpt_platform/backend/README.md index c76fe40ba419..d8c275e7b6fa 100644 --- a/autogpt_platform/backend/README.md +++ b/autogpt_platform/backend/README.md @@ -1,203 +1 @@ -# AutoGPT Agent Server - -This is an initial project for creating the next generation of agent execution, which is an AutoGPT agent server. -The agent server will enable the creation of composite multi-agent systems that utilize AutoGPT agents and other non-agent components as its primitives. - -## Docs - -You can access the docs for the [AutoGPT Agent Server here](https://docs.agpt.co/server/setup). - -## Setup - -We use the Poetry to manage the dependencies. To set up the project, follow these steps inside this directory: - -0. Install Poetry - ```sh - pip install poetry - ``` - -1. Configure Poetry to use .venv in your project directory - ```sh - poetry config virtualenvs.in-project true - ``` - -2. Enter the poetry shell - - ```sh - poetry shell - ``` - -3. Install dependencies - - ```sh - poetry install - ``` - -4. Copy .env.example to .env - - ```sh - cp .env.example .env - ``` - -5. Generate the Prisma client - - ```sh - poetry run prisma generate - ``` - - - > In case Prisma generates the client for the global Python installation instead of the virtual environment, the current mitigation is to just uninstall the global Prisma package: - > - > ```sh - > pip uninstall prisma - > ``` - > - > Then run the generation again. The path *should* look something like this: - > `/pypoetry/virtualenvs/backend-TQIRSwR6-py3.12/bin/prisma` - -6. Migrate the database. Be careful because this deletes current data in the database. - - ```sh - docker compose up db -d - poetry run prisma migrate deploy - ``` - -## Running The Server - -### Starting the server without Docker - -Run the following command to run database in docker but the application locally: - -```sh -docker compose --profile local up deps --build --detach -poetry run app -``` - -### Starting the server with Docker - -Run the following command to build the dockerfiles: - -```sh -docker compose build -``` - -Run the following command to run the app: - -```sh -docker compose up -``` - -Run the following to automatically rebuild when code changes, in another terminal: - -```sh -docker compose watch -``` - -Run the following command to shut down: - -```sh -docker compose down -``` - -If you run into issues with dangling orphans, try: - -```sh -docker compose down --volumes --remove-orphans && docker-compose up --force-recreate --renew-anon-volumes --remove-orphans -``` - -## Testing - -To run the tests: - -```sh -poetry run test -``` - -## Development - -### Formatting & Linting -Auto formatter and linter are set up in the project. To run them: - -Install: -```sh -poetry install --with dev -``` - -Format the code: -```sh -poetry run format -``` - -Lint the code: -```sh -poetry run lint -``` - -## Project Outline - -The current project has the following main modules: - -### **blocks** - -This module stores all the Agent Blocks, which are reusable components to build a graph that represents the agent's behavior. - -### **data** - -This module stores the logical model that is persisted in the database. -It abstracts the database operations into functions that can be called by the service layer. -Any code that interacts with Prisma objects or the database should reside in this module. -The main models are: -* `block`: anything related to the block used in the graph -* `execution`: anything related to the execution graph execution -* `graph`: anything related to the graph, node, and its relations - -### **execution** - -This module stores the business logic of executing the graph. -It currently has the following main modules: -* `manager`: A service that consumes the queue of the graph execution and executes the graph. It contains both pieces of logic. -* `scheduler`: A service that triggers scheduled graph execution based on a cron expression. It pushes an execution request to the manager. - -### **server** - -This module stores the logic for the server API. -It contains all the logic used for the API that allows the client to create, execute, and monitor the graph and its execution. -This API service interacts with other services like those defined in `manager` and `scheduler`. - -### **utils** - -This module stores utility functions that are used across the project. -Currently, it has two main modules: -* `process`: A module that contains the logic to spawn a new process. -* `service`: A module that serves as a parent class for all the services in the project. - -## Service Communication - -Currently, there are only 3 active services: - -- AgentServer (the API, defined in `server.py`) -- ExecutionManager (the executor, defined in `manager.py`) -- ExecutionScheduler (the scheduler, defined in `scheduler.py`) - -The services run in independent Python processes and communicate through an IPC. -A communication layer (`service.py`) is created to decouple the communication library from the implementation. - -Currently, the IPC is done using Pyro5 and abstracted in a way that allows a function decorated with `@expose` to be called from a different process. - - -By default the daemons run on the following ports: - -Execution Manager Daemon: 8002 -Execution Scheduler Daemon: 8003 -Rest Server Daemon: 8004 - -## Adding a New Agent Block - -To add a new agent block, you need to create a new class that inherits from `Block` and provides the following information: -* All the block code should live in the `blocks` (`backend.blocks`) module. -* `input_schema`: the schema of the input data, represented by a Pydantic object. -* `output_schema`: the schema of the output data, represented by a Pydantic object. -* `run` method: the main logic of the block. -* `test_input` & `test_output`: the sample input and output data for the block, which will be used to auto-test the block. -* You can mock the functions declared in the block using the `test_mock` field for your unit tests. -* Once you finish creating the block, you can test it by running `poetry run pytest -s test/block/test_block.py`. +[Getting Started (Released)](https://docs.agpt.co/platform/getting-started/#autogpt_agent_server) \ No newline at end of file diff --git a/autogpt_platform/backend/TESTING.md b/autogpt_platform/backend/TESTING.md new file mode 100644 index 000000000000..1fb79732fa74 --- /dev/null +++ b/autogpt_platform/backend/TESTING.md @@ -0,0 +1,237 @@ +# Backend Testing Guide + +This guide covers testing practices for the AutoGPT Platform backend, with a focus on snapshot testing for API endpoints. + +## Table of Contents +- [Overview](#overview) +- [Running Tests](#running-tests) +- [Snapshot Testing](#snapshot-testing) +- [Writing Tests for API Routes](#writing-tests-for-api-routes) +- [Best Practices](#best-practices) + +## Overview + +The backend uses pytest for testing with the following key libraries: +- `pytest` - Test framework +- `pytest-asyncio` - Async test support +- `pytest-mock` - Mocking support +- `pytest-snapshot` - Snapshot testing for API responses + +## Running Tests + +### Run all tests +```bash +poetry run test +``` + +### Run specific test file +```bash +poetry run pytest path/to/test_file.py +``` + +### Run with verbose output +```bash +poetry run pytest -v +``` + +### Run with coverage +```bash +poetry run pytest --cov=backend +``` + +## Snapshot Testing + +Snapshot testing captures the output of your code and compares it against previously saved snapshots. This is particularly useful for testing API responses. + +### How Snapshot Testing Works + +1. First run: Creates snapshot files in `snapshots/` directories +2. Subsequent runs: Compares output against saved snapshots +3. Changes detected: Test fails if output differs from snapshot + +### Creating/Updating Snapshots + +When you first write a test or when the expected output changes: + +```bash +poetry run pytest path/to/test.py --snapshot-update +``` + +⚠️ **Important**: Always review snapshot changes before committing! Use `git diff` to verify the changes are expected. + +### Snapshot Test Example + +```python +import json +from pytest_snapshot.plugin import Snapshot + +def test_api_endpoint(snapshot: Snapshot): + response = client.get("/api/endpoint") + + # Snapshot the response + snapshot.snapshot_dir = "snapshots" +snapshot.assert_match( + json.dumps(response.json(), indent=2, sort_keys=True), + "endpoint_response" + ) +``` + +### Best Practices for Snapshots + +1. **Use descriptive names**: `"user_list_response"` not `"response1"` +2. **Sort JSON keys**: Ensures consistent snapshots +3. **Format JSON**: Use `indent=2` for readable diffs +4. **Exclude dynamic data**: Remove timestamps, IDs, etc. that change between runs + +Example of excluding dynamic data: +```python +response_data = response.json() +# Remove dynamic fields for snapshot +response_data.pop("created_at", None) +response_data.pop("id", None) + +snapshot.snapshot_dir = "snapshots" +snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "static_response_data" +) +``` + +## Writing Tests for API Routes + +### Basic Structure + +```python +import json +import fastapi +import fastapi.testclient +import pytest +from pytest_snapshot.plugin import Snapshot + +from backend.server.v2.myroute import router + +app = fastapi.FastAPI() +app.include_router(router) +client = fastapi.testclient.TestClient(app) + +def test_endpoint_success(snapshot: Snapshot): + response = client.get("/endpoint") + assert response.status_code == 200 + + # Test specific fields + data = response.json() + assert data["status"] == "success" + + # Snapshot the full response + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(data, indent=2, sort_keys=True), + "endpoint_success_response" + ) +``` + +### Testing with Authentication + +```python +def override_auth_middleware(): + return {"sub": "test-user-id"} + +def override_get_user_id(): + return "test-user-id" + +app.dependency_overrides[auth_middleware] = override_auth_middleware +app.dependency_overrides[get_user_id] = override_get_user_id +``` + +### Mocking External Services + +```python +def test_external_api_call(mocker, snapshot): + # Mock external service + mock_response = {"external": "data"} + mocker.patch( + "backend.services.external_api.call", + return_value=mock_response + ) + + response = client.post("/api/process") + assert response.status_code == 200 + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response.json(), indent=2, sort_keys=True), + "process_with_external_response" + ) +``` + +## Best Practices + +### 1. Test Organization +- Place tests next to the code: `routes.py` → `routes_test.py` +- Use descriptive test names: `test_create_user_with_invalid_email` +- Group related tests in classes when appropriate + +### 2. Test Coverage +- Test happy path and error cases +- Test edge cases (empty data, invalid formats) +- Test authentication and authorization + +### 3. Snapshot Testing Guidelines +- Review all snapshot changes carefully +- Don't snapshot sensitive data +- Keep snapshots focused and minimal +- Update snapshots intentionally, not accidentally + +### 4. Async Testing +- Use regular `def` for FastAPI TestClient tests +- Use `async def` with `@pytest.mark.asyncio` for testing async functions directly + +### 5. Fixtures +Create reusable fixtures for common test data: + +```python +@pytest.fixture +def sample_user(): + return { + "email": "test@example.com", + "name": "Test User" + } + +def test_create_user(sample_user, snapshot): + response = client.post("/users", json=sample_user) + # ... test implementation +``` + +## CI/CD Integration + +The GitHub Actions workflow automatically runs tests on: +- Pull requests +- Pushes to main branch + +Snapshot tests work in CI by: +1. Committing snapshot files to the repository +2. CI compares against committed snapshots +3. Fails if snapshots don't match + +## Troubleshooting + +### Snapshot Mismatches +- Review the diff carefully +- If changes are expected: `poetry run pytest --snapshot-update` +- If changes are unexpected: Fix the code causing the difference + +### Async Test Issues +- Ensure async functions use `@pytest.mark.asyncio` +- Use `AsyncMock` for mocking async functions +- FastAPI TestClient handles async automatically + +### Import Errors +- Check that all dependencies are in `pyproject.toml` +- Run `poetry install` to ensure dependencies are installed +- Verify import paths are correct + +## Summary + +Snapshot testing provides a powerful way to ensure API responses remain consistent. Combined with traditional assertions, it creates a robust test suite that catches regressions while remaining maintainable. + +Remember: Good tests are as important as good code! \ No newline at end of file diff --git a/autogpt_platform/backend/backend/TEST_DATA_README.md b/autogpt_platform/backend/backend/TEST_DATA_README.md new file mode 100644 index 000000000000..ef818df25030 --- /dev/null +++ b/autogpt_platform/backend/backend/TEST_DATA_README.md @@ -0,0 +1,150 @@ +# Test Data Scripts + +This directory contains scripts for creating and updating test data in the AutoGPT Platform database, specifically designed to test the materialized views for the store functionality. + +## Scripts + +### test_data_creator.py +Creates a comprehensive set of test data including: +- Users with profiles +- Agent graphs, nodes, and executions +- Store listings with multiple versions +- Reviews and ratings +- Library agents +- Integration webhooks +- Onboarding data +- Credit transactions + +**Image/Video Domains Used:** +- Images: `picsum.photos` (for all image URLs) +- Videos: `youtube.com` (for store listing videos) + +### test_data_updater.py +Updates existing test data to simulate real-world changes: +- Adds new agent graph executions +- Creates new store listing reviews +- Updates store listing versions +- Adds credit transactions +- Refreshes materialized views + +### check_db.py +Tests and verifies materialized views functionality: +- Checks pg_cron job status (for automatic refresh) +- Displays current materialized view counts +- Adds test data (executions and reviews) +- Creates store listings if none exist +- Manually refreshes materialized views +- Compares before/after counts to verify updates +- Provides a summary of test results + +## Materialized Views + +The scripts test three key database views: + +1. **mv_agent_run_counts**: Tracks execution counts by agent +2. **mv_review_stats**: Tracks review statistics (count, average rating) by store listing +3. **StoreAgent**: A view that combines store listing data with execution counts and ratings for display + +The materialized views (mv_agent_run_counts and mv_review_stats) are automatically refreshed every 15 minutes via pg_cron, or can be manually refreshed using the `refresh_store_materialized_views()` function. + +## Usage + +### Prerequisites + +1. Ensure the database is running: +```bash +docker compose up -d +# or for test database: +docker compose -f docker-compose.test.yaml --env-file ../.env up -d +``` + +2. Run database migrations: +```bash +poetry run prisma migrate deploy +``` + +### Running the Scripts + +#### Option 1: Use the helper script (from backend directory) +```bash +poetry run python run_test_data.py +``` + +#### Option 2: Run individually +```bash +# From backend/test directory: +# Create initial test data +poetry run python test_data_creator.py + +# Update data to test materialized view changes +poetry run python test_data_updater.py + +# From backend directory: +# Test materialized views functionality +poetry run python check_db.py + +# Check store data status +poetry run python check_store_data.py +``` + +#### Option 3: Use the shell script (from backend directory) +```bash +./run_test_data_scripts.sh +``` + +### Manual Materialized View Refresh + +To manually refresh the materialized views: +```sql +SELECT refresh_store_materialized_views(); +``` + +## Configuration + +The scripts use the database configuration from your `.env` file: +- `DATABASE_URL`: PostgreSQL connection string +- Database should have the platform schema + +## Data Generation Limits + +Configured in `test_data_creator.py`: +- 100 users +- 100 agent blocks +- 1-5 graphs per user +- 2-5 nodes per graph +- 1-5 presets per user +- 1-10 library agents per user +- 1-20 executions per graph +- 1-5 reviews per store listing version + +## Notes + +- All image URLs use `picsum.photos` for consistency with Next.js image configuration +- The scripts create realistic relationships between entities +- Materialized views are refreshed at the end of each script +- Data is designed to test both happy paths and edge cases + +## Troubleshooting + +### Reviews and StoreAgent view showing 0 + +If `check_db.py` shows that reviews remain at 0 and StoreAgent view shows 0 store agents: + +1. **No store listings exist**: The script will automatically create test store listings if none exist +2. **No approved versions**: Store listings need approved versions to appear in the StoreAgent view +3. **Check with `check_store_data.py`**: This script provides detailed information about: + - Total store listings + - Store listing versions by status + - Existing reviews + - StoreAgent view contents + - Agent graph executions + +### pg_cron not installed + +The warning "pg_cron extension is not installed" is normal in local development environments. The materialized views can still be refreshed manually using the `refresh_store_materialized_views()` function, which all scripts do automatically. + +### Common Issues + +- **Type errors with None values**: Fixed in the latest version of check_db.py by using `or 0` for nullable numeric fields +- **Missing relations**: Ensure you're using the correct field names (e.g., `StoreListing` not `storeListing` in includes) +- **Column name mismatches**: The database uses camelCase for column names (e.g., `agentGraphId` not `agent_graph_id`) \ No newline at end of file diff --git a/autogpt_platform/backend/backend/app.py b/autogpt_platform/backend/backend/app.py index 5d77ea9632b3..596962ae0bd8 100644 --- a/autogpt_platform/backend/backend/app.py +++ b/autogpt_platform/backend/backend/app.py @@ -1,22 +1,34 @@ +import logging from typing import TYPE_CHECKING +from dotenv import load_dotenv + +load_dotenv() + if TYPE_CHECKING: from backend.util.process import AppProcess +logger = logging.getLogger(__name__) + def run_processes(*processes: "AppProcess", **kwargs): """ Execute all processes in the app. The last process is run in the foreground. + Includes enhanced error handling and process lifecycle management. """ try: + # Run all processes except the last one in the background. for process in processes[:-1]: process.start(background=True, **kwargs) - # Run the last process in the foreground + # Run the last process in the foreground. processes[-1].start(background=False, **kwargs) finally: for process in processes: - process.stop() + try: + process.stop() + except Exception as e: + logger.exception(f"[{process.service_name}] unable to stop: {e}") def main(**kwargs): @@ -24,16 +36,18 @@ def main(**kwargs): Run all the processes required for the AutoGPT-server (REST and WebSocket APIs). """ - from backend.executor import DatabaseManager, ExecutionManager, ExecutionScheduler + from backend.executor import DatabaseManager, ExecutionManager, Scheduler + from backend.notifications import NotificationManager from backend.server.rest_api import AgentServer from backend.server.ws_api import WebsocketServer run_processes( - DatabaseManager(), - ExecutionManager(), - ExecutionScheduler(), + DatabaseManager().set_log_level("warning"), + Scheduler(), + NotificationManager(), WebsocketServer(), AgentServer(), + ExecutionManager(), **kwargs, ) diff --git a/autogpt_platform/backend/backend/blocks/__init__.py b/autogpt_platform/backend/backend/blocks/__init__.py index 11c5f3d5d6d2..f6299cbb53df 100644 --- a/autogpt_platform/backend/backend/blocks/__init__.py +++ b/autogpt_platform/backend/backend/blocks/__init__.py @@ -1,89 +1,124 @@ +import functools import importlib +import logging import os import re from pathlib import Path -from typing import Type, TypeVar +from typing import TYPE_CHECKING, TypeVar -from backend.data.block import Block +logger = logging.getLogger(__name__) -# Dynamically load all modules under backend.blocks -AVAILABLE_MODULES = [] -current_dir = Path(__file__).parent -modules = [ - str(f.relative_to(current_dir))[:-3].replace(os.path.sep, ".") - for f in current_dir.rglob("*.py") - if f.is_file() and f.name != "__init__.py" -] -for module in modules: - if not re.match("^[a-z0-9_.]+$", module): - raise ValueError( - f"Block module {module} error: module name must be lowercase, " - "and contain only alphanumeric characters and underscores." - ) - importlib.import_module(f".{module}", package=__name__) - AVAILABLE_MODULES.append(module) +if TYPE_CHECKING: + from backend.data.block import Block -# Load all Block instances from the available modules -AVAILABLE_BLOCKS: dict[str, Type[Block]] = {} +T = TypeVar("T") -T = TypeVar("T") +@functools.cache +def load_all_blocks() -> dict[str, type["Block"]]: + from backend.data.block import Block + from backend.util.settings import Config + # Check if example blocks should be loaded from settings + config = Config() + load_examples = config.enable_example_blocks -def all_subclasses(cls: Type[T]) -> list[Type[T]]: - subclasses = cls.__subclasses__() - for subclass in subclasses: - subclasses += all_subclasses(subclass) - return subclasses + # Dynamically load all modules under backend.blocks + current_dir = Path(__file__).parent + modules = [] + for f in current_dir.rglob("*.py"): + if not f.is_file() or f.name == "__init__.py" or f.name.startswith("test_"): + continue + # Skip examples directory if not enabled + relative_path = f.relative_to(current_dir) + if not load_examples and relative_path.parts[0] == "examples": + continue -for block_cls in all_subclasses(Block): - name = block_cls.__name__ + module_path = str(relative_path)[:-3].replace(os.path.sep, ".") + modules.append(module_path) - if block_cls.__name__.endswith("Base"): - continue + for module in modules: + if not re.match("^[a-z0-9_.]+$", module): + raise ValueError( + f"Block module {module} error: module name must be lowercase, " + "and contain only alphanumeric characters and underscores." + ) - if not block_cls.__name__.endswith("Block"): - raise ValueError( - f"Block class {block_cls.__name__} does not end with 'Block', If you are creating an abstract class, please name the class with 'Base' at the end" - ) + importlib.import_module(f".{module}", package=__name__) - block = block_cls.create() + # Load all Block instances from the available modules + available_blocks: dict[str, type["Block"]] = {} + for block_cls in all_subclasses(Block): + class_name = block_cls.__name__ - if not isinstance(block.id, str) or len(block.id) != 36: - raise ValueError(f"Block ID {block.name} error: {block.id} is not a valid UUID") + if class_name.endswith("Base"): + continue - if block.id in AVAILABLE_BLOCKS: - raise ValueError(f"Block ID {block.name} error: {block.id} is already in use") + if not class_name.endswith("Block"): + raise ValueError( + f"Block class {class_name} does not end with 'Block'. " + "If you are creating an abstract class, " + "please name the class with 'Base' at the end" + ) - input_schema = block.input_schema.model_fields - output_schema = block.output_schema.model_fields + block = block_cls.create() - # Make sure `error` field is a string in the output schema - if "error" in output_schema and output_schema["error"].annotation is not str: - raise ValueError( - f"{block.name} `error` field in output_schema must be a string" - ) + if not isinstance(block.id, str) or len(block.id) != 36: + raise ValueError( + f"Block ID {block.name} error: {block.id} is not a valid UUID" + ) - # Make sure all fields in input_schema and output_schema are annotated and has a value - for field_name, field in [*input_schema.items(), *output_schema.items()]: - if field.annotation is None: + if block.id in available_blocks: raise ValueError( - f"{block.name} has a field {field_name} that is not annotated" + f"Block ID {block.name} error: {block.id} is already in use" ) - if field.json_schema_extra is None: + + input_schema = block.input_schema.model_fields + output_schema = block.output_schema.model_fields + + # Make sure `error` field is a string in the output schema + if "error" in output_schema and output_schema["error"].annotation is not str: raise ValueError( - f"{block.name} has a field {field_name} not defined as SchemaField" + f"{block.name} `error` field in output_schema must be a string" ) - for field in block.input_schema.model_fields.values(): - if field.annotation is bool and field.default not in (True, False): - raise ValueError(f"{block.name} has a boolean field with no default value") + # Ensure all fields in input_schema and output_schema are annotated SchemaFields + for field_name, field in [*input_schema.items(), *output_schema.items()]: + if field.annotation is None: + raise ValueError( + f"{block.name} has a field {field_name} that is not annotated" + ) + if field.json_schema_extra is None: + raise ValueError( + f"{block.name} has a field {field_name} not defined as SchemaField" + ) + + for field in block.input_schema.model_fields.values(): + if field.annotation is bool and field.default not in (True, False): + raise ValueError( + f"{block.name} has a boolean field with no default value" + ) + + available_blocks[block.id] = block_cls + + # Filter out blocks with incomplete auth configs, e.g. missing OAuth server secrets + from backend.data.block import is_block_auth_configured + + filtered_blocks = {} + for block_id, block_cls in available_blocks.items(): + if is_block_auth_configured(block_cls): + filtered_blocks[block_id] = block_cls - if block.disabled: - continue + return filtered_blocks - AVAILABLE_BLOCKS[block.id] = block_cls -__all__ = ["AVAILABLE_MODULES", "AVAILABLE_BLOCKS"] +__all__ = ["load_all_blocks"] + + +def all_subclasses(cls: type[T]) -> list[type[T]]: + subclasses = cls.__subclasses__() + for subclass in subclasses: + subclasses += all_subclasses(subclass) + return subclasses diff --git a/autogpt_platform/backend/backend/blocks/agent.py b/autogpt_platform/backend/backend/blocks/agent.py index afbe410e4291..ac3eedb12bdc 100644 --- a/autogpt_platform/backend/backend/blocks/agent.py +++ b/autogpt_platform/backend/backend/blocks/agent.py @@ -1,6 +1,7 @@ import logging +from typing import Any, Optional -from autogpt_libs.utils.cache import thread_cached +from pydantic import JsonValue from backend.data.block import ( Block, @@ -12,24 +13,11 @@ get_block, ) from backend.data.execution import ExecutionStatus -from backend.data.model import SchemaField +from backend.data.model import NodeExecutionStats, SchemaField +from backend.util.json import validate_with_jsonschema +from backend.util.retry import func_retry -logger = logging.getLogger(__name__) - - -@thread_cached -def get_executor_manager_client(): - from backend.executor import ExecutionManager - from backend.util.service import get_service_client - - return get_service_client(ExecutionManager) - - -@thread_cached -def get_event_bus(): - from backend.data.execution import RedisExecutionEventBus - - return RedisExecutionEventBus() +_logger = logging.getLogger(__name__) class AgentExecutorBlock(Block): @@ -37,11 +25,35 @@ class Input(BlockSchema): user_id: str = SchemaField(description="User ID") graph_id: str = SchemaField(description="Graph ID") graph_version: int = SchemaField(description="Graph Version") + agent_name: Optional[str] = SchemaField( + default=None, description="Name to display in the Builder UI" + ) - data: BlockInput = SchemaField(description="Input data for the graph") + inputs: BlockInput = SchemaField(description="Input data for the graph") input_schema: dict = SchemaField(description="Input schema for the graph") output_schema: dict = SchemaField(description="Output schema for the graph") + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = SchemaField( + default=None, hidden=True + ) + + @classmethod + def get_input_schema(cls, data: BlockInput) -> dict[str, Any]: + return data.get("input_schema", {}) + + @classmethod + def get_input_defaults(cls, data: BlockInput) -> BlockInput: + return data.get("inputs", {}) + + @classmethod + def get_missing_input(cls, data: BlockInput) -> set[str]: + required_fields = cls.get_input_schema(data).get("required", []) + return set(required_fields) - set(data) + + @classmethod + def get_mismatch_error(cls, data: BlockInput) -> str | None: + return validate_with_jsonschema(cls.get_input_schema(data), data) + class Output(BlockSchema): pass @@ -55,32 +67,103 @@ def __init__(self): categories={BlockCategory.AGENT}, ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: - executor_manager = get_executor_manager_client() - event_bus = get_event_bus() + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + + from backend.executor import utils as execution_utils - graph_exec = executor_manager.add_execution( + graph_exec = await execution_utils.add_graph_execution( graph_id=input_data.graph_id, graph_version=input_data.graph_version, user_id=input_data.user_id, - data=input_data.data, + inputs=input_data.inputs, + nodes_input_masks=input_data.nodes_input_masks, + ) + + logger = execution_utils.LogMetadata( + logger=_logger, + user_id=input_data.user_id, + graph_eid=graph_exec.id, + graph_id=input_data.graph_id, + node_eid="*", + node_id="*", + block_name=self.name, ) - log_id = f"Graph #{input_data.graph_id}-V{input_data.graph_version}, exec-id: {graph_exec.graph_exec_id}" + + try: + async for name, data in self._run( + graph_id=input_data.graph_id, + graph_version=input_data.graph_version, + graph_exec_id=graph_exec.id, + user_id=input_data.user_id, + logger=logger, + ): + yield name, data + except BaseException as e: + await self._stop( + graph_exec_id=graph_exec.id, + user_id=input_data.user_id, + logger=logger, + ) + logger.warning( + f"Execution of graph {input_data.graph_id}v{input_data.graph_version} failed: {e.__class__.__name__} {str(e)}; execution is stopped." + ) + raise + + async def _run( + self, + graph_id: str, + graph_version: int, + graph_exec_id: str, + user_id: str, + logger, + ) -> BlockOutput: + + from backend.data.execution import ExecutionEventType + from backend.executor import utils as execution_utils + + event_bus = execution_utils.get_async_execution_event_bus() + + log_id = f"Graph #{graph_id}-V{graph_version}, exec-id: {graph_exec_id}" logger.info(f"Starting execution of {log_id}") + yielded_node_exec_ids = set() - for event in event_bus.listen( - graph_id=graph_exec.graph_id, graph_exec_id=graph_exec.graph_exec_id + async for event in event_bus.listen( + user_id=user_id, + graph_id=graph_id, + graph_exec_id=graph_exec_id, ): - logger.info( + if event.status not in [ + ExecutionStatus.COMPLETED, + ExecutionStatus.TERMINATED, + ExecutionStatus.FAILED, + ]: + logger.debug( + f"Execution {log_id} received event {event.event_type} with status {event.status}" + ) + continue + + if event.event_type == ExecutionEventType.GRAPH_EXEC_UPDATE: + # If the graph execution is COMPLETED, TERMINATED, or FAILED, + # we can stop listening for further events. + self.merge_stats( + NodeExecutionStats( + extra_cost=event.stats.cost if event.stats else 0, + extra_steps=event.stats.node_exec_count if event.stats else 0, + ) + ) + break + + logger.debug( f"Execution {log_id} produced input {event.input_data} output {event.output_data}" ) - if not event.node_id: - if event.status in [ExecutionStatus.COMPLETED, ExecutionStatus.FAILED]: - logger.info(f"Execution {log_id} ended with status {event.status}") - break - else: - continue + if event.node_exec_id in yielded_node_exec_ids: + logger.warning( + f"{log_id} received duplicate event for node execution {event.node_exec_id}" + ) + continue + else: + yielded_node_exec_ids.add(event.node_exec_id) if not event.block_id: logger.warning(f"{log_id} received event without block_id {event}") @@ -96,5 +179,29 @@ def run(self, input_data: Input, **kwargs) -> BlockOutput: continue for output_data in event.output_data.get("output", []): - logger.info(f"Execution {log_id} produced {output_name}: {output_data}") + logger.debug( + f"Execution {log_id} produced {output_name}: {output_data}" + ) yield output_name, output_data + + @func_retry + async def _stop( + self, + graph_exec_id: str, + user_id: str, + logger, + ) -> None: + from backend.executor import utils as execution_utils + + log_id = f"Graph exec-id: {graph_exec_id}" + logger.info(f"Stopping execution of {log_id}") + + try: + await execution_utils.stop_graph_execution( + graph_exec_id=graph_exec_id, + user_id=user_id, + wait_timeout=3600, + ) + logger.info(f"Execution {log_id} stopped successfully.") + except TimeoutError as e: + logger.error(f"Execution {log_id} stop timed out: {e}") diff --git a/autogpt_platform/backend/backend/blocks/ai_image_generator_block.py b/autogpt_platform/backend/backend/blocks/ai_image_generator_block.py index ebd79dda9ac9..39c0d4ac5471 100644 --- a/autogpt_platform/backend/backend/blocks/ai_image_generator_block.py +++ b/autogpt_platform/backend/backend/blocks/ai_image_generator_block.py @@ -1,8 +1,8 @@ from enum import Enum from typing import Literal -import replicate from pydantic import SecretStr +from replicate.client import Client as ReplicateClient from replicate.helpers import FileOutput from backend.data.block import Block, BlockCategory, BlockSchema @@ -165,15 +165,15 @@ def __init__(self): }, ) - def _run_client( + async def _run_client( self, credentials: APIKeyCredentials, model_name: str, input_params: dict ): try: # Initialize Replicate client - client = replicate.Client(api_token=credentials.api_key.get_secret_value()) + client = ReplicateClient(api_token=credentials.api_key.get_secret_value()) # Run the model with input parameters - output = client.run(model_name, input=input_params, wait=False) + output = await client.async_run(model_name, input=input_params, wait=False) # Process output if isinstance(output, list) and len(output) > 0: @@ -195,7 +195,7 @@ def _run_client( except Exception as e: raise RuntimeError(f"Unexpected error during model execution: {e}") - def generate_image(self, input_data: Input, credentials: APIKeyCredentials): + async def generate_image(self, input_data: Input, credentials: APIKeyCredentials): try: # Handle style-based prompt modification for models without native style support modified_prompt = input_data.prompt @@ -213,7 +213,7 @@ def generate_image(self, input_data: Input, credentials: APIKeyCredentials): "steps": 40, "cfg_scale": 7.0, } - output = self._run_client( + output = await self._run_client( credentials, "stability-ai/stable-diffusion-3.5-medium", input_params, @@ -231,7 +231,7 @@ def generate_image(self, input_data: Input, credentials: APIKeyCredentials): "output_format": "jpg", # Set to jpg for Flux models "output_quality": 90, } - output = self._run_client( + output = await self._run_client( credentials, "black-forest-labs/flux-1.1-pro", input_params ) return output @@ -246,7 +246,7 @@ def generate_image(self, input_data: Input, credentials: APIKeyCredentials): "output_format": "jpg", "output_quality": 90, } - output = self._run_client( + output = await self._run_client( credentials, "black-forest-labs/flux-1.1-pro-ultra", input_params ) return output @@ -257,7 +257,7 @@ def generate_image(self, input_data: Input, credentials: APIKeyCredentials): "size": SIZE_TO_RECRAFT_DIMENSIONS[input_data.size], "style": input_data.style.value, } - output = self._run_client( + output = await self._run_client( credentials, "recraft-ai/recraft-v3", input_params ) return output @@ -296,9 +296,9 @@ def _style_to_prompt_prefix(self, style: ImageStyle) -> str: style_text = style_map.get(style, "") return f"{style_text} of" if style_text else "" - def run(self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs): + async def run(self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs): try: - url = self.generate_image(input_data, credentials) + url = await self.generate_image(input_data, credentials) if url: yield "image_url", url else: diff --git a/autogpt_platform/backend/backend/blocks/ai_music_generator.py b/autogpt_platform/backend/backend/blocks/ai_music_generator.py index 708203510877..92182fb16aee 100644 --- a/autogpt_platform/backend/backend/blocks/ai_music_generator.py +++ b/autogpt_platform/backend/backend/blocks/ai_music_generator.py @@ -1,10 +1,10 @@ +import asyncio import logging -import time from enum import Enum from typing import Literal -import replicate from pydantic import SecretStr +from replicate.client import Client as ReplicateClient from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import ( @@ -142,7 +142,7 @@ def __init__(self): test_credentials=TEST_CREDENTIALS, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: max_retries = 3 @@ -154,7 +154,7 @@ def run( logger.debug( f"[AIMusicGeneratorBlock] - Running model (attempt {attempt + 1})" ) - result = self.run_model( + result = await self.run_model( api_key=credentials.api_key, music_gen_model_version=input_data.music_gen_model_version, prompt=input_data.prompt, @@ -166,7 +166,7 @@ def run( output_format=input_data.output_format, normalization_strategy=input_data.normalization_strategy, ) - if result and result != "No output received": + if result and isinstance(result, str) and result.startswith("http"): yield "result", result return else: @@ -176,13 +176,13 @@ def run( last_error = f"Unexpected error: {str(e)}" logger.error(f"[AIMusicGeneratorBlock] - Error: {last_error}") if attempt < max_retries - 1: - time.sleep(retry_delay) + await asyncio.sleep(retry_delay) continue # If we've exhausted all retries, yield the error yield "error", f"Failed after {max_retries} attempts. Last error: {last_error}" - def run_model( + async def run_model( self, api_key: SecretStr, music_gen_model_version: MusicGenModelVersion, @@ -196,10 +196,10 @@ def run_model( normalization_strategy: NormalizationStrategy, ): # Initialize Replicate client with the API key - client = replicate.Client(api_token=api_key.get_secret_value()) + client = ReplicateClient(api_token=api_key.get_secret_value()) # Run the model with parameters - output = client.run( + output = await client.async_run( "meta/musicgen:671ac645ce5e552cc63a54a2bbff63fcf798043055d2dac5fc9e36a837eedcfb", input={ "prompt": prompt, diff --git a/autogpt_platform/backend/backend/blocks/ai_shortform_video_block.py b/autogpt_platform/backend/backend/blocks/ai_shortform_video_block.py index df2b3a27263c..fe1368797018 100644 --- a/autogpt_platform/backend/backend/blocks/ai_shortform_video_block.py +++ b/autogpt_platform/backend/backend/blocks/ai_shortform_video_block.py @@ -1,3 +1,4 @@ +import asyncio import logging import time from enum import Enum @@ -13,7 +14,7 @@ SchemaField, ) from backend.integrations.providers import ProviderName -from backend.util.request import requests +from backend.util.request import Requests TEST_CREDENTIALS = APIKeyCredentials( id="01234567-89ab-cdef-0123-456789abcdef", @@ -52,6 +53,7 @@ class AudioTrack(str, Enum): REFRESHER = ("Refresher",) TOURIST = ("Tourist",) TWIN_TYCHES = ("Twin Tyches",) + DONT_STOP_ME_ABSTRACT_FUTURE_BASS = ("Dont Stop Me Abstract Future Bass",) @property def audio_url(self): @@ -77,6 +79,7 @@ def audio_url(self): AudioTrack.REFRESHER: "https://cdn.tfrv.xyz/audio/refresher.mp3", AudioTrack.TOURIST: "https://cdn.tfrv.xyz/audio/tourist.mp3", AudioTrack.TWIN_TYCHES: "https://cdn.tfrv.xyz/audio/twin-tynches.mp3", + AudioTrack.DONT_STOP_ME_ABSTRACT_FUTURE_BASS: "https://cdn.revid.ai/audio/_dont-stop-me-abstract-future-bass.mp3", } return audio_urls[self] @@ -104,6 +107,7 @@ class GenerationPreset(str, Enum): MOVIE = ("Movie",) STYLIZED_ILLUSTRATION = ("Stylized Illustration",) MANGA = ("Manga",) + DEFAULT = ("DEFAULT",) class Voice(str, Enum): @@ -113,6 +117,7 @@ class Voice(str, Enum): JESSICA = "Jessica" CHARLOTTE = "Charlotte" CALLUM = "Callum" + EVA = "Eva" @property def voice_id(self): @@ -123,6 +128,7 @@ def voice_id(self): Voice.JESSICA: "cgSgspJ2msm6clMCkdW9", Voice.CHARLOTTE: "XB0fDUnXU5powFXDhCwa", Voice.CALLUM: "N2lVS1w4EtoT3dr4eOWO", + Voice.EVA: "FGY2WhTYpPnrIDTdsKH5", } return voice_id_map[self] @@ -140,6 +146,8 @@ class VisualMediaType(str, Enum): class AIShortformVideoCreatorBlock(Block): + """Creates a short‑form text‑to‑video clip using stock or AI imagery.""" + class Input(BlockSchema): credentials: CredentialsMetaInput[ Literal[ProviderName.REVID], Literal["api_key"] @@ -183,71 +191,41 @@ class Output(BlockSchema): video_url: str = SchemaField(description="The URL of the created video") error: str = SchemaField(description="Error message if the request failed") - def __init__(self): - super().__init__( - id="361697fb-0c4f-4feb-aed3-8320c88c771b", - description="Creates a shortform video using revid.ai", - categories={BlockCategory.SOCIAL, BlockCategory.AI}, - input_schema=AIShortformVideoCreatorBlock.Input, - output_schema=AIShortformVideoCreatorBlock.Output, - test_input={ - "credentials": TEST_CREDENTIALS_INPUT, - "script": "[close-up of a cat] Meow!", - "ratio": "9 / 16", - "resolution": "720p", - "frame_rate": 60, - "generation_preset": GenerationPreset.LEONARDO, - "background_music": AudioTrack.HIGHWAY_NOCTURNE, - "voice": Voice.LILY, - "video_style": VisualMediaType.STOCK_VIDEOS, - }, - test_output=( - "video_url", - "https://example.com/video.mp4", - ), - test_mock={ - "create_webhook": lambda: ( - "test_uuid", - "https://webhook.site/test_uuid", - ), - "create_video": lambda api_key, payload: {"pid": "test_pid"}, - "wait_for_video": lambda api_key, pid, webhook_token, max_wait_time=1000: "https://example.com/video.mp4", - }, - test_credentials=TEST_CREDENTIALS, - ) - - def create_webhook(self): + async def create_webhook(self) -> tuple[str, str]: + """Create a new webhook URL for receiving notifications.""" url = "https://webhook.site/token" headers = {"Accept": "application/json", "Content-Type": "application/json"} - response = requests.post(url, headers=headers) + response = await Requests().post(url, headers=headers) webhook_data = response.json() return webhook_data["uuid"], f"https://webhook.site/{webhook_data['uuid']}" - def create_video(self, api_key: SecretStr, payload: dict) -> dict: + async def create_video(self, api_key: SecretStr, payload: dict) -> dict: + """Create a video using the Revid API.""" url = "https://www.revid.ai/api/public/v2/render" headers = {"key": api_key.get_secret_value()} - response = requests.post(url, json=payload, headers=headers) + response = await Requests().post(url, json=payload, headers=headers) logger.debug( - f"API Response Status Code: {response.status_code}, Content: {response.text}" + f"API Response Status Code: {response.status}, Content: {response.text}" ) return response.json() - def check_video_status(self, api_key: SecretStr, pid: str) -> dict: + async def check_video_status(self, api_key: SecretStr, pid: str) -> dict: + """Check the status of a video creation job.""" url = f"https://www.revid.ai/api/public/v2/status?pid={pid}" headers = {"key": api_key.get_secret_value()} - response = requests.get(url, headers=headers) + response = await Requests().get(url, headers=headers) return response.json() - def wait_for_video( + async def wait_for_video( self, api_key: SecretStr, pid: str, - webhook_token: str, max_wait_time: int = 1000, ) -> str: + """Wait for video creation to complete and return the video URL.""" start_time = time.time() while time.time() - start_time < max_wait_time: - status = self.check_video_status(api_key, pid) + status = await self.check_video_status(api_key, pid) logger.debug(f"Video status: {status}") if status.get("status") == "ready" and "videoUrl" in status: @@ -260,32 +238,64 @@ def wait_for_video( logger.error(f"Video creation failed: {status.get('message')}") raise ValueError(f"Video creation failed: {status.get('message')}") - time.sleep(10) + await asyncio.sleep(10) logger.error("Video creation timed out") raise TimeoutError("Video creation timed out") - def run( + def __init__(self): + super().__init__( + id="361697fb-0c4f-4feb-aed3-8320c88c771b", + description="Creates a shortform video using revid.ai", + categories={BlockCategory.SOCIAL, BlockCategory.AI}, + input_schema=AIShortformVideoCreatorBlock.Input, + output_schema=AIShortformVideoCreatorBlock.Output, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "script": "[close-up of a cat] Meow!", + "ratio": "9 / 16", + "resolution": "720p", + "frame_rate": 60, + "generation_preset": GenerationPreset.LEONARDO, + "background_music": AudioTrack.HIGHWAY_NOCTURNE, + "voice": Voice.LILY, + "video_style": VisualMediaType.STOCK_VIDEOS, + }, + test_output=("video_url", "https://example.com/video.mp4"), + test_mock={ + "create_webhook": lambda *args, **kwargs: ( + "test_uuid", + "https://webhook.site/test_uuid", + ), + "create_video": lambda *args, **kwargs: {"pid": "test_pid"}, + "check_video_status": lambda *args, **kwargs: { + "status": "ready", + "videoUrl": "https://example.com/video.mp4", + }, + "wait_for_video": lambda *args, **kwargs: "https://example.com/video.mp4", + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: # Create a new Webhook.site URL - webhook_token, webhook_url = self.create_webhook() + webhook_token, webhook_url = await self.create_webhook() logger.debug(f"Webhook URL: {webhook_url}") - audio_url = input_data.background_music.audio_url - payload = { "frameRate": input_data.frame_rate, "resolution": input_data.resolution, "frameDurationMultiplier": 18, - "webhook": webhook_url, + "webhook": None, "creationParams": { "mediaType": input_data.video_style, "captionPresetName": "Wrap 1", "selectedVoice": input_data.voice.voice_id, "hasEnhancedGeneration": True, "generationPreset": input_data.generation_preset.name, - "selectedAudio": input_data.background_music, + "selectedAudio": input_data.background_music.value, "origin": "/create", "inputText": input_data.script, "flowType": "text-to-video", @@ -301,12 +311,12 @@ def run( "selectedStoryStyle": {"value": "custom", "label": "Custom"}, "hasToGenerateVideos": input_data.video_style != VisualMediaType.STOCK_VIDEOS, - "audioUrl": audio_url, + "audioUrl": input_data.background_music.audio_url, }, } logger.debug("Creating video...") - response = self.create_video(credentials.api_key, payload) + response = await self.create_video(credentials.api_key, payload) pid = response.get("pid") if not pid: @@ -318,6 +328,370 @@ def run( logger.debug( f"Video created with project ID: {pid}. Waiting for completion..." ) - video_url = self.wait_for_video(credentials.api_key, pid, webhook_token) + video_url = await self.wait_for_video(credentials.api_key, pid) logger.debug(f"Video ready: {video_url}") yield "video_url", video_url + + +class AIAdMakerVideoCreatorBlock(Block): + """Generates a 30‑second vertical AI advert using optional user‑supplied imagery.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput[ + Literal[ProviderName.REVID], Literal["api_key"] + ] = CredentialsField( + description="Credentials for Revid.ai API access.", + ) + script: str = SchemaField( + description="Short advertising copy. Line breaks create new scenes.", + placeholder="Introducing Foobar – [show product photo] the gadget that does it all.", + ) + ratio: str = SchemaField(description="Aspect ratio", default="9 / 16") + target_duration: int = SchemaField( + description="Desired length of the ad in seconds.", default=30 + ) + voice: Voice = SchemaField( + description="Narration voice", default=Voice.EVA, placeholder=Voice.EVA + ) + background_music: AudioTrack = SchemaField( + description="Background track", + default=AudioTrack.DONT_STOP_ME_ABSTRACT_FUTURE_BASS, + ) + input_media_urls: list[str] = SchemaField( + description="List of image URLs to feature in the advert.", default=[] + ) + use_only_provided_media: bool = SchemaField( + description="Restrict visuals to supplied images only.", default=True + ) + + class Output(BlockSchema): + video_url: str = SchemaField(description="URL of the finished advert") + error: str = SchemaField(description="Error message on failure") + + async def create_webhook(self) -> tuple[str, str]: + """Create a new webhook URL for receiving notifications.""" + url = "https://webhook.site/token" + headers = {"Accept": "application/json", "Content-Type": "application/json"} + response = await Requests().post(url, headers=headers) + webhook_data = response.json() + return webhook_data["uuid"], f"https://webhook.site/{webhook_data['uuid']}" + + async def create_video(self, api_key: SecretStr, payload: dict) -> dict: + """Create a video using the Revid API.""" + url = "https://www.revid.ai/api/public/v2/render" + headers = {"key": api_key.get_secret_value()} + response = await Requests().post(url, json=payload, headers=headers) + logger.debug( + f"API Response Status Code: {response.status}, Content: {response.text}" + ) + return response.json() + + async def check_video_status(self, api_key: SecretStr, pid: str) -> dict: + """Check the status of a video creation job.""" + url = f"https://www.revid.ai/api/public/v2/status?pid={pid}" + headers = {"key": api_key.get_secret_value()} + response = await Requests().get(url, headers=headers) + return response.json() + + async def wait_for_video( + self, + api_key: SecretStr, + pid: str, + max_wait_time: int = 1000, + ) -> str: + """Wait for video creation to complete and return the video URL.""" + start_time = time.time() + while time.time() - start_time < max_wait_time: + status = await self.check_video_status(api_key, pid) + logger.debug(f"Video status: {status}") + + if status.get("status") == "ready" and "videoUrl" in status: + return status["videoUrl"] + elif status.get("status") == "error": + error_message = status.get("error", "Unknown error occurred") + logger.error(f"Video creation failed: {error_message}") + raise ValueError(f"Video creation failed: {error_message}") + elif status.get("status") in ["FAILED", "CANCELED"]: + logger.error(f"Video creation failed: {status.get('message')}") + raise ValueError(f"Video creation failed: {status.get('message')}") + + await asyncio.sleep(10) + + logger.error("Video creation timed out") + raise TimeoutError("Video creation timed out") + + def __init__(self): + super().__init__( + id="58bd2a19-115d-4fd1-8ca4-13b9e37fa6a0", + description="Creates an AI‑generated 30‑second advert (text + images)", + categories={BlockCategory.MARKETING, BlockCategory.AI}, + input_schema=AIAdMakerVideoCreatorBlock.Input, + output_schema=AIAdMakerVideoCreatorBlock.Output, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "script": "Test product launch!", + "input_media_urls": [ + "https://cdn.revid.ai/uploads/1747076315114-image.png", + ], + }, + test_output=("video_url", "https://example.com/ad.mp4"), + test_mock={ + "create_webhook": lambda *args, **kwargs: ( + "test_uuid", + "https://webhook.site/test_uuid", + ), + "create_video": lambda *args, **kwargs: {"pid": "test_pid"}, + "check_video_status": lambda *args, **kwargs: { + "status": "ready", + "videoUrl": "https://example.com/ad.mp4", + }, + "wait_for_video": lambda *args, **kwargs: "https://example.com/ad.mp4", + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def run(self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs): + webhook_token, webhook_url = await self.create_webhook() + + payload = { + "webhook": webhook_url, + "creationParams": { + "targetDuration": input_data.target_duration, + "ratio": input_data.ratio, + "mediaType": "aiVideo", + "inputText": input_data.script, + "flowType": "text-to-video", + "slug": "ai-ad-generator", + "slugNew": "", + "isCopiedFrom": False, + "hasToGenerateVoice": True, + "hasToTranscript": False, + "hasToSearchMedia": True, + "hasAvatar": False, + "hasWebsiteRecorder": False, + "hasTextSmallAtBottom": False, + "selectedAudio": input_data.background_music.value, + "selectedVoice": input_data.voice.voice_id, + "selectedAvatar": "https://cdn.revid.ai/avatars/young-woman.mp4", + "selectedAvatarType": "video/mp4", + "websiteToRecord": "", + "hasToGenerateCover": True, + "nbGenerations": 1, + "disableCaptions": False, + "mediaMultiplier": "medium", + "characters": [], + "captionPresetName": "Revid", + "sourceType": "contentScraping", + "selectedStoryStyle": {"value": "custom", "label": "General"}, + "generationPreset": "DEFAULT", + "hasToGenerateMusic": False, + "isOptimizedForChinese": False, + "generationUserPrompt": "", + "enableNsfwFilter": False, + "addStickers": False, + "typeMovingImageAnim": "dynamic", + "hasToGenerateSoundEffects": False, + "forceModelType": "gpt-image-1", + "selectedCharacters": [], + "lang": "", + "voiceSpeed": 1, + "disableAudio": False, + "disableVoice": False, + "useOnlyProvidedMedia": input_data.use_only_provided_media, + "imageGenerationModel": "ultra", + "videoGenerationModel": "pro", + "hasEnhancedGeneration": True, + "hasEnhancedGenerationPro": True, + "inputMedias": [ + {"url": url, "title": "", "type": "image"} + for url in input_data.input_media_urls + ], + "hasToGenerateVideos": True, + "audioUrl": input_data.background_music.audio_url, + "watermark": None, + }, + } + + response = await self.create_video(credentials.api_key, payload) + pid = response.get("pid") + if not pid: + raise RuntimeError("Failed to create video: No project ID returned") + + video_url = await self.wait_for_video(credentials.api_key, pid) + yield "video_url", video_url + + +class AIScreenshotToVideoAdBlock(Block): + """Creates an advert where the supplied screenshot is narrated by an AI avatar.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput[ + Literal[ProviderName.REVID], Literal["api_key"] + ] = CredentialsField(description="Revid.ai API key") + script: str = SchemaField( + description="Narration that will accompany the screenshot.", + placeholder="Check out these amazing stats!", + ) + screenshot_url: str = SchemaField( + description="Screenshot or image URL to showcase." + ) + ratio: str = SchemaField(default="9 / 16") + target_duration: int = SchemaField(default=30) + voice: Voice = SchemaField(default=Voice.EVA) + background_music: AudioTrack = SchemaField( + default=AudioTrack.DONT_STOP_ME_ABSTRACT_FUTURE_BASS + ) + + class Output(BlockSchema): + video_url: str = SchemaField(description="Rendered video URL") + error: str = SchemaField(description="Error, if encountered") + + async def create_webhook(self) -> tuple[str, str]: + """Create a new webhook URL for receiving notifications.""" + url = "https://webhook.site/token" + headers = {"Accept": "application/json", "Content-Type": "application/json"} + response = await Requests().post(url, headers=headers) + webhook_data = response.json() + return webhook_data["uuid"], f"https://webhook.site/{webhook_data['uuid']}" + + async def create_video(self, api_key: SecretStr, payload: dict) -> dict: + """Create a video using the Revid API.""" + url = "https://www.revid.ai/api/public/v2/render" + headers = {"key": api_key.get_secret_value()} + response = await Requests().post(url, json=payload, headers=headers) + logger.debug( + f"API Response Status Code: {response.status}, Content: {response.text}" + ) + return response.json() + + async def check_video_status(self, api_key: SecretStr, pid: str) -> dict: + """Check the status of a video creation job.""" + url = f"https://www.revid.ai/api/public/v2/status?pid={pid}" + headers = {"key": api_key.get_secret_value()} + response = await Requests().get(url, headers=headers) + return response.json() + + async def wait_for_video( + self, + api_key: SecretStr, + pid: str, + max_wait_time: int = 1000, + ) -> str: + """Wait for video creation to complete and return the video URL.""" + start_time = time.time() + while time.time() - start_time < max_wait_time: + status = await self.check_video_status(api_key, pid) + logger.debug(f"Video status: {status}") + + if status.get("status") == "ready" and "videoUrl" in status: + return status["videoUrl"] + elif status.get("status") == "error": + error_message = status.get("error", "Unknown error occurred") + logger.error(f"Video creation failed: {error_message}") + raise ValueError(f"Video creation failed: {error_message}") + elif status.get("status") in ["FAILED", "CANCELED"]: + logger.error(f"Video creation failed: {status.get('message')}") + raise ValueError(f"Video creation failed: {status.get('message')}") + + await asyncio.sleep(10) + + logger.error("Video creation timed out") + raise TimeoutError("Video creation timed out") + + def __init__(self): + super().__init__( + id="0f3e4635-e810-43d9-9e81-49e6f4e83b7c", + description="Turns a screenshot into an engaging, avatar‑narrated video advert.", + categories={BlockCategory.AI, BlockCategory.MARKETING}, + input_schema=AIScreenshotToVideoAdBlock.Input, + output_schema=AIScreenshotToVideoAdBlock.Output, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "script": "Amazing numbers!", + "screenshot_url": "https://cdn.revid.ai/uploads/1747080376028-image.png", + }, + test_output=("video_url", "https://example.com/screenshot.mp4"), + test_mock={ + "create_webhook": lambda *args, **kwargs: ( + "test_uuid", + "https://webhook.site/test_uuid", + ), + "create_video": lambda *args, **kwargs: {"pid": "test_pid"}, + "check_video_status": lambda *args, **kwargs: { + "status": "ready", + "videoUrl": "https://example.com/screenshot.mp4", + }, + "wait_for_video": lambda *args, **kwargs: "https://example.com/screenshot.mp4", + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def run(self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs): + webhook_token, webhook_url = await self.create_webhook() + + payload = { + "webhook": webhook_url, + "creationParams": { + "targetDuration": input_data.target_duration, + "ratio": input_data.ratio, + "mediaType": "aiVideo", + "hasAvatar": True, + "removeAvatarBackground": True, + "inputText": input_data.script, + "flowType": "text-to-video", + "slug": "ai-ad-generator", + "slugNew": "screenshot-to-video-ad", + "isCopiedFrom": "ai-ad-generator", + "hasToGenerateVoice": True, + "hasToTranscript": False, + "hasToSearchMedia": True, + "hasWebsiteRecorder": False, + "hasTextSmallAtBottom": False, + "selectedAudio": input_data.background_music.value, + "selectedVoice": input_data.voice.voice_id, + "selectedAvatar": "https://cdn.revid.ai/avatars/young-woman.mp4", + "selectedAvatarType": "video/mp4", + "websiteToRecord": "", + "hasToGenerateCover": True, + "nbGenerations": 1, + "disableCaptions": False, + "mediaMultiplier": "medium", + "characters": [], + "captionPresetName": "Revid", + "sourceType": "contentScraping", + "selectedStoryStyle": {"value": "custom", "label": "General"}, + "generationPreset": "DEFAULT", + "hasToGenerateMusic": False, + "isOptimizedForChinese": False, + "generationUserPrompt": "", + "enableNsfwFilter": False, + "addStickers": False, + "typeMovingImageAnim": "dynamic", + "hasToGenerateSoundEffects": False, + "forceModelType": "gpt-image-1", + "selectedCharacters": [], + "lang": "", + "voiceSpeed": 1, + "disableAudio": False, + "disableVoice": False, + "useOnlyProvidedMedia": True, + "imageGenerationModel": "ultra", + "videoGenerationModel": "ultra", + "hasEnhancedGeneration": True, + "hasEnhancedGenerationPro": True, + "inputMedias": [ + {"url": input_data.screenshot_url, "title": "", "type": "image"} + ], + "hasToGenerateVideos": True, + "audioUrl": input_data.background_music.audio_url, + "watermark": None, + }, + } + + response = await self.create_video(credentials.api_key, payload) + pid = response.get("pid") + if not pid: + raise RuntimeError("Failed to create video: No project ID returned") + + video_url = await self.wait_for_video(credentials.api_key, pid) + yield "video_url", video_url diff --git a/autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/__init__.py b/autogpt_platform/backend/backend/blocks/airtable/__init__.py similarity index 100% rename from autogpt_platform/autogpt_libs/autogpt_libs/feature_flag/__init__.py rename to autogpt_platform/backend/backend/blocks/airtable/__init__.py diff --git a/autogpt_platform/backend/backend/blocks/airtable/_api.py b/autogpt_platform/backend/backend/blocks/airtable/_api.py new file mode 100644 index 000000000000..4fb9602d9317 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/airtable/_api.py @@ -0,0 +1,1251 @@ +import base64 +from enum import Enum +from logging import getLogger +from typing import Any +from urllib.parse import quote, urlencode + +from backend.sdk import BaseModel, Credentials, Requests + +logger = getLogger(__name__) + + +def _convert_bools( + obj: Any, +) -> Any: # noqa: ANN401 – allow Any for deep conversion utility + """Recursively walk *obj* and coerce string booleans to real booleans.""" + if isinstance(obj, str): + lowered = obj.lower() + if lowered == "true": + return True + if lowered == "false": + return False + return obj + if isinstance(obj, list): + return [_convert_bools(item) for item in obj] + if isinstance(obj, dict): + return {k: _convert_bools(v) for k, v in obj.items()} + return obj + + +class WebhookFilters(BaseModel): + dataTypes: list[str] + changeTypes: list[str] | None = None + fromSources: list[str] | None = None + sourceOptions: dict | None = None + watchDataInFieldIds: list[str] | None = None + watchSchemasOfFieldIds: list[str] | None = None + + +class WebhookIncludes(BaseModel): + includeCellValuesInFieldIds: list[str] | str | None = None + includePreviousCellValues: bool | None = None + includePreviousFieldDefinitions: bool | None = None + + +class WebhookSpecification(BaseModel): + recordChangeScope: str | None = None + filters: WebhookFilters + includes: WebhookIncludes | None = None + + +class WebhookPayload(BaseModel): + actionMetadata: dict + baseTransactionNumber: int + payloadFormat: str + timestamp: str + changedTablesById: dict | None = None + createdTablesById: dict | None = None + destroyedTableIds: list[str] | None = None + error: bool | None = None + code: str | None = None + + +class ListWebhookPayloadsResponse(BaseModel): + payloads: list[WebhookPayload] + cursor: int | None = None + might_have_more: bool | None = None + payloadFormat: str + + +class TableFieldType(str, Enum): + SINGLE_LINE_TEXT = "singleLineText" + EMAIL = "email" + URL = "url" + MULTILINE_TEXT = "multilineText" + NUMBER = "number" + PERCENT = "percent" + CURRENCY = "currency" + SINGLE_SELECT = "singleSelect" + MULTIPLE_SELECTS = "multipleSelects" + SINGLE_COLLABORATOR = "singleCollaborator" + MULTIPLE_COLLABORATORS = "multipleCollaborators" + MULTIPLE_RECORD_LINKS = "multipleRecordLinks" + DATE = "date" + DATE_TIME = "dateTime" + PHONE_NUMBER = "phoneNumber" + MULTIPLE_ATTACHMENTS = "multipleAttachments" + CHECKBOX = "checkbox" + FORMULA = "formula" + CREATED_TIME = "createdTime" + ROLLUP = "rollup" + COUNT = "count" + LOOKUP = "lookup" + MULTIPLE_LOOKUP_VALUES = "multipleLookupValues" + AUTO_NUMBER = "autoNumber" + BARCODE = "barcode" + RATING = "rating" + RICH_TEXT = "richText" + DURATION = "duration" + LAST_MODIFIED_TIME = "lastModifiedTime" + BUTTON = "button" + CREATED_BY = "createdBy" + LAST_MODIFIED_BY = "lastModifiedBy" + EXTERNAL_SYNC_SOURCE = "externalSyncSource" + AI_TEXT = "aiText" + + +TABLE_FIELD_TYPES = set(type.value for type in TableFieldType) + + +class AirtableTimeZones(str, Enum): + UTC = "utc" + CLIENT = "client" + AFRICA_ABIDJAN = "Africa/Abidjan" + AFRICA_ACCRA = "Africa/Accra" + AFRICA_ADDIS_ABABA = "Africa/Addis_Ababa" + AFRICA_ALGIERS = "Africa/Algiers" + AFRICA_ASMARA = "Africa/Asmara" + AFRICA_BAMAKO = "Africa/Bamako" + AFRICA_BANGUI = "Africa/Bangui" + AFRICA_BANJUL = "Africa/Banjul" + AFRICA_BISSAU = "Africa/Bissau" + AFRICA_BLANTYRE = "Africa/Blantyre" + AFRICA_BRAZZAVILLE = "Africa/Brazzaville" + AFRICA_BUJUMBURA = "Africa/Bujumbura" + AFRICA_CAIRO = "Africa/Cairo" + AFRICA_CASABLANCA = "Africa/Casablanca" + AFRICA_CEUTA = "Africa/Ceuta" + AFRICA_CONAKRY = "Africa/Conakry" + AFRICA_DAKAR = "Africa/Dakar" + AFRICA_DAR_ES_SALAAM = "Africa/Dar_es_Salaam" + AFRICA_DJIBOUTI = "Africa/Djibouti" + AFRICA_DOUALA = "Africa/Douala" + AFRICA_EL_AAIUN = "Africa/El_Aaiun" + AFRICA_FREETOWN = "Africa/Freetown" + AFRICA_GABORONE = "Africa/Gaborone" + AFRICA_HARARE = "Africa/Harare" + AFRICA_JOHANNESBURG = "Africa/Johannesburg" + AFRICA_JUBA = "Africa/Juba" + AFRICA_KAMPALA = "Africa/Kampala" + AFRICA_KHARTOUM = "Africa/Khartoum" + AFRICA_KIGALI = "Africa/Kigali" + AFRICA_KINSHASA = "Africa/Kinshasa" + AFRICA_LAGOS = "Africa/Lagos" + AFRICA_LIBREVILLE = "Africa/Libreville" + AFRICA_LOME = "Africa/Lome" + AFRICA_LUANDA = "Africa/Luanda" + AFRICA_LUBUMBASHI = "Africa/Lubumbashi" + AFRICA_LUSAKA = "Africa/Lusaka" + AFRICA_MALABO = "Africa/Malabo" + AFRICA_MAPUTO = "Africa/Maputo" + AFRICA_MASERU = "Africa/Maseru" + AFRICA_MBABANE = "Africa/Mbabane" + AFRICA_MOGADISHU = "Africa/Mogadishu" + AFRICA_MONROVIA = "Africa/Monrovia" + AFRICA_NAIROBI = "Africa/Nairobi" + AFRICA_NDJAMENA = "Africa/Ndjamena" + AFRICA_NIAMEY = "Africa/Niamey" + AFRICA_NOUAKCHOTT = "Africa/Nouakchott" + AFRICA_OUAGADOUGOU = "Africa/Ouagadougou" + AFRICA_PORTO_NOVO = "Africa/Porto-Novo" + AFRICA_SAO_TOME = "Africa/Sao_Tome" + AFRICA_TRIPOLI = "Africa/Tripoli" + AFRICA_TUNIS = "Africa/Tunis" + AFRICA_WINDHOEK = "Africa/Windhoek" + AMERICA_ADAK = "America/Adak" + AMERICA_ANCHORAGE = "America/Anchorage" + AMERICA_ANGUILLA = "America/Anguilla" + AMERICA_ANTIGUA = "America/Antigua" + AMERICA_ARAGUAINA = "America/Araguaina" + AMERICA_ARGENTINA_BUENOS_AIRES = "America/Argentina/Buenos_Aires" + AMERICA_ARGENTINA_CATAMARCA = "America/Argentina/Catamarca" + AMERICA_ARGENTINA_CORDOBA = "America/Argentina/Cordoba" + AMERICA_ARGENTINA_JUJUY = "America/Argentina/Jujuy" + AMERICA_ARGENTINA_LA_RIOJA = "America/Argentina/La_Rioja" + AMERICA_ARGENTINA_MENDOZA = "America/Argentina/Mendoza" + AMERICA_ARGENTINA_RIO_GALLEGOS = "America/Argentina/Rio_Gallegos" + AMERICA_ARGENTINA_SALTA = "America/Argentina/Salta" + AMERICA_ARGENTINA_SAN_JUAN = "America/Argentina/San_Juan" + AMERICA_ARGENTINA_SAN_LUIS = "America/Argentina/San_Luis" + AMERICA_ARGENTINA_TUCUMAN = "America/Argentina/Tucuman" + AMERICA_ARGENTINA_USHUAIA = "America/Argentina/Ushuaia" + AMERICA_ARUBA = "America/Aruba" + AMERICA_ASUNCION = "America/Asuncion" + AMERICA_ATIKOKAN = "America/Atikokan" + AMERICA_BAHIA = "America/Bahia" + AMERICA_BAHIA_BANDERAS = "America/Bahia_Banderas" + AMERICA_BARBADOS = "America/Barbados" + AMERICA_BELEM = "America/Belem" + AMERICA_BELIZE = "America/Belize" + AMERICA_BLANC_SABLON = "America/Blanc-Sablon" + AMERICA_BOA_VISTA = "America/Boa_Vista" + AMERICA_BOGOTA = "America/Bogota" + AMERICA_BOISE = "America/Boise" + AMERICA_CAMBRIDGE_BAY = "America/Cambridge_Bay" + AMERICA_CAMPO_GRANDE = "America/Campo_Grande" + AMERICA_CANCUN = "America/Cancun" + AMERICA_CARACAS = "America/Caracas" + AMERICA_CAYENNE = "America/Cayenne" + AMERICA_CAYMAN = "America/Cayman" + AMERICA_CHICAGO = "America/Chicago" + AMERICA_CHIHUAHUA = "America/Chihuahua" + AMERICA_COSTA_RICA = "America/Costa_Rica" + AMERICA_CRESTON = "America/Creston" + AMERICA_CUIABA = "America/Cuiaba" + AMERICA_CURACAO = "America/Curacao" + AMERICA_DANMARKSHAVN = "America/Danmarkshavn" + AMERICA_DAWSON = "America/Dawson" + AMERICA_DAWSON_CREEK = "America/Dawson_Creek" + AMERICA_DENVER = "America/Denver" + AMERICA_DETROIT = "America/Detroit" + AMERICA_DOMINICA = "America/Dominica" + AMERICA_EDMONTON = "America/Edmonton" + AMERICA_EIRUNEPE = "America/Eirunepe" + AMERICA_EL_SALVADOR = "America/El_Salvador" + AMERICA_FORT_NELSON = "America/Fort_Nelson" + AMERICA_FORTALEZA = "America/Fortaleza" + AMERICA_GLACE_BAY = "America/Glace_Bay" + AMERICA_GODTHAB = "America/Godthab" + AMERICA_GOOSE_BAY = "America/Goose_Bay" + AMERICA_GRAND_TURK = "America/Grand_Turk" + AMERICA_GRENADA = "America/Grenada" + AMERICA_GUADELOUPE = "America/Guadeloupe" + AMERICA_GUATEMALA = "America/Guatemala" + AMERICA_GUAYAQUIL = "America/Guayaquil" + AMERICA_GUYANA = "America/Guyana" + AMERICA_HALIFAX = "America/Halifax" + AMERICA_HAVANA = "America/Havana" + AMERICA_HERMOSILLO = "America/Hermosillo" + AMERICA_INDIANA_INDIANAPOLIS = "America/Indiana/Indianapolis" + AMERICA_INDIANA_KNOX = "America/Indiana/Knox" + AMERICA_INDIANA_MARENGO = "America/Indiana/Marengo" + AMERICA_INDIANA_PETERSBURG = "America/Indiana/Petersburg" + AMERICA_INDIANA_TELL_CITY = "America/Indiana/Tell_City" + AMERICA_INDIANA_VEVAY = "America/Indiana/Vevay" + AMERICA_INDIANA_VINCENNES = "America/Indiana/Vincennes" + AMERICA_INDIANA_WINAMAC = "America/Indiana/Winamac" + AMERICA_INUVIK = "America/Inuvik" + AMERICA_IQALUIT = "America/Iqaluit" + AMERICA_JAMAICA = "America/Jamaica" + AMERICA_JUNEAU = "America/Juneau" + AMERICA_KENTUCKY_LOUISVILLE = "America/Kentucky/Louisville" + AMERICA_KENTUCKY_MONTICELLO = "America/Kentucky/Monticello" + AMERICA_KRALENDIJK = "America/Kralendijk" + AMERICA_LA_PAZ = "America/La_Paz" + AMERICA_LIMA = "America/Lima" + AMERICA_LOS_ANGELES = "America/Los_Angeles" + AMERICA_LOWER_PRINCES = "America/Lower_Princes" + AMERICA_MACEIO = "America/Maceio" + AMERICA_MANAGUA = "America/Managua" + AMERICA_MANAUS = "America/Manaus" + AMERICA_MARIGOT = "America/Marigot" + AMERICA_MARTINIQUE = "America/Martinique" + AMERICA_MATAMOROS = "America/Matamoros" + AMERICA_MAZATLAN = "America/Mazatlan" + AMERICA_MENOMINEE = "America/Menominee" + AMERICA_MERIDA = "America/Merida" + AMERICA_METLAKATLA = "America/Metlakatla" + AMERICA_MEXICO_CITY = "America/Mexico_City" + AMERICA_MIQUELON = "America/Miquelon" + AMERICA_MONCTON = "America/Moncton" + AMERICA_MONTERREY = "America/Monterrey" + AMERICA_MONTEVIDEO = "America/Montevideo" + AMERICA_MONTSERRAT = "America/Montserrat" + AMERICA_NASSAU = "America/Nassau" + AMERICA_NEW_YORK = "America/New_York" + AMERICA_NIPIGON = "America/Nipigon" + AMERICA_NOME = "America/Nome" + AMERICA_NORONHA = "America/Noronha" + AMERICA_NORTH_DAKOTA_BEULAH = "America/North_Dakota/Beulah" + AMERICA_NORTH_DAKOTA_CENTER = "America/North_Dakota/Center" + AMERICA_NORTH_DAKOTA_NEW_SALEM = "America/North_Dakota/New_Salem" + AMERICA_NUUK = "America/Nuuk" + AMERICA_OJINAGA = "America/Ojinaga" + AMERICA_PANAMA = "America/Panama" + AMERICA_PANGNIRTUNG = "America/Pangnirtung" + AMERICA_PARAMARIBO = "America/Paramaribo" + AMERICA_PHOENIX = "America/Phoenix" + AMERICA_PORT_AU_PRINCE = "America/Port-au-Prince" + AMERICA_PORT_OF_SPAIN = "America/Port_of_Spain" + AMERICA_PORTO_VELHO = "America/Porto_Velho" + AMERICA_PUERTO_RICO = "America/Puerto_Rico" + AMERICA_PUNTA_ARENAS = "America/Punta_Arenas" + AMERICA_RAINY_RIVER = "America/Rainy_River" + AMERICA_RANKIN_INLET = "America/Rankin_Inlet" + AMERICA_RECIFE = "America/Recife" + AMERICA_REGINA = "America/Regina" + AMERICA_RESOLUTE = "America/Resolute" + AMERICA_RIO_BRANCO = "America/Rio_Branco" + AMERICA_SANTAREM = "America/Santarem" + AMERICA_SANTIAGO = "America/Santiago" + AMERICA_SANTO_DOMINGO = "America/Santo_Domingo" + AMERICA_SAO_PAULO = "America/Sao_Paulo" + AMERICA_SCORESBYSUND = "America/Scoresbysund" + AMERICA_SITKA = "America/Sitka" + AMERICA_ST_BARTHELEMY = "America/St_Barthelemy" + AMERICA_ST_JOHNS = "America/St_Johns" + AMERICA_ST_KITTS = "America/St_Kitts" + AMERICA_ST_LUCIA = "America/St_Lucia" + AMERICA_ST_THOMAS = "America/St_Thomas" + AMERICA_ST_VINCENT = "America/St_Vincent" + AMERICA_SWIFT_CURRENT = "America/Swift_Current" + AMERICA_TEGUCIGALPA = "America/Tegucigalpa" + AMERICA_THULE = "America/Thule" + AMERICA_THUNDER_BAY = "America/Thunder_Bay" + AMERICA_TIJUANA = "America/Tijuana" + AMERICA_TORONTO = "America/Toronto" + AMERICA_TORTOLA = "America/Tortola" + AMERICA_VANCOUVER = "America/Vancouver" + AMERICA_WHITEHORSE = "America/Whitehorse" + AMERICA_WINNIPEG = "America/Winnipeg" + AMERICA_YAKUTAT = "America/Yakutat" + AMERICA_YELLOWKNIFE = "America/Yellowknife" + ANTARCTICA_CASEY = "Antarctica/Casey" + ANTARCTICA_DAVIS = "Antarctica/Davis" + ANTARCTICA_DUMONT_DURVILLE = "Antarctica/DumontDUrville" + ANTARCTICA_MACQUARIE = "Antarctica/Macquarie" + ANTARCTICA_MAWSON = "Antarctica/Mawson" + ANTARCTICA_MCMURDO = "Antarctica/McMurdo" + ANTARCTICA_PALMER = "Antarctica/Palmer" + ANTARCTICA_ROTHERA = "Antarctica/Rothera" + ANTARCTICA_SYOWA = "Antarctica/Syowa" + ANTARCTICA_TROLL = "Antarctica/Troll" + ANTARCTICA_VOSTOK = "Antarctica/Vostok" + ARCTIC_LONGYEARBYEN = "Arctic/Longyearbyen" + ASIA_ADEN = "Asia/Aden" + ASIA_ALMATY = "Asia/Almaty" + ASIA_AMMAN = "Asia/Amman" + ASIA_ANADYR = "Asia/Anadyr" + ASIA_AQTAU = "Asia/Aqtau" + ASIA_AQTOBE = "Asia/Aqtobe" + ASIA_ASHGABAT = "Asia/Ashgabat" + ASIA_ATYRAU = "Asia/Atyrau" + ASIA_BAGHDAD = "Asia/Baghdad" + ASIA_BAHRAIN = "Asia/Bahrain" + ASIA_BAKU = "Asia/Baku" + ASIA_BANGKOK = "Asia/Bangkok" + ASIA_BARNAUL = "Asia/Barnaul" + ASIA_BEIRUT = "Asia/Beirut" + ASIA_BISHKEK = "Asia/Bishkek" + ASIA_BRUNEI = "Asia/Brunei" + ASIA_CHITA = "Asia/Chita" + ASIA_CHOIBALSAN = "Asia/Choibalsan" + ASIA_COLOMBO = "Asia/Colombo" + ASIA_DAMASCUS = "Asia/Damascus" + ASIA_DHAKA = "Asia/Dhaka" + ASIA_DILI = "Asia/Dili" + ASIA_DUBAI = "Asia/Dubai" + ASIA_DUSHANBE = "Asia/Dushanbe" + ASIA_FAMAGUSTA = "Asia/Famagusta" + ASIA_GAZA = "Asia/Gaza" + ASIA_HEBRON = "Asia/Hebron" + ASIA_HO_CHI_MINH = "Asia/Ho_Chi_Minh" + ASIA_HONG_KONG = "Asia/Hong_Kong" + ASIA_HOVD = "Asia/Hovd" + ASIA_IRKUTSK = "Asia/Irkutsk" + ASIA_ISTANBUL = "Asia/Istanbul" + ASIA_JAKARTA = "Asia/Jakarta" + ASIA_JAYAPURA = "Asia/Jayapura" + ASIA_JERUSALEM = "Asia/Jerusalem" + ASIA_KABUL = "Asia/Kabul" + ASIA_KAMCHATKA = "Asia/Kamchatka" + ASIA_KARACHI = "Asia/Karachi" + ASIA_KATHMANDU = "Asia/Kathmandu" + ASIA_KHANDYGA = "Asia/Khandyga" + ASIA_KOLKATA = "Asia/Kolkata" + ASIA_KRASNOYARSK = "Asia/Krasnoyarsk" + ASIA_KUALA_LUMPUR = "Asia/Kuala_Lumpur" + ASIA_KUCHING = "Asia/Kuching" + ASIA_KUWAIT = "Asia/Kuwait" + ASIA_MACAU = "Asia/Macau" + ASIA_MAGADAN = "Asia/Magadan" + ASIA_MAKASSAR = "Asia/Makassar" + ASIA_MANILA = "Asia/Manila" + ASIA_MUSCAT = "Asia/Muscat" + ASIA_NICOSIA = "Asia/Nicosia" + ASIA_NOVOKUZNETSK = "Asia/Novokuznetsk" + ASIA_NOVOSIBIRSK = "Asia/Novosibirsk" + ASIA_OMSK = "Asia/Omsk" + ASIA_ORAL = "Asia/Oral" + ASIA_PHNOM_PENH = "Asia/Phnom_Penh" + ASIA_PONTIANAK = "Asia/Pontianak" + ASIA_PYONGYANG = "Asia/Pyongyang" + ASIA_QATAR = "Asia/Qatar" + ASIA_QOSTANAY = "Asia/Qostanay" + ASIA_QYZYLORDA = "Asia/Qyzylorda" + ASIA_RANGOON = "Asia/Rangoon" + ASIA_RIYADH = "Asia/Riyadh" + ASIA_SAKHALIN = "Asia/Sakhalin" + ASIA_SAMARKAND = "Asia/Samarkand" + ASIA_SEOUL = "Asia/Seoul" + ASIA_SHANGHAI = "Asia/Shanghai" + ASIA_SINGAPORE = "Asia/Singapore" + ASIA_SREDNEKOLYMSK = "Asia/Srednekolymsk" + ASIA_TAIPEI = "Asia/Taipei" + ASIA_TASHKENT = "Asia/Tashkent" + ASIA_TBILISI = "Asia/Tbilisi" + ASIA_TEHRAN = "Asia/Tehran" + ASIA_THIMPHU = "Asia/Thimphu" + ASIA_TOKYO = "Asia/Tokyo" + ASIA_TOMSK = "Asia/Tomsk" + ASIA_ULAANBAATAR = "Asia/Ulaanbaatar" + ASIA_URUMQI = "Asia/Urumqi" + ASIA_UST_NERA = "Asia/Ust-Nera" + ASIA_VIENTIANE = "Asia/Vientiane" + ASIA_VLADIVOSTOK = "Asia/Vladivostok" + ASIA_YAKUTSK = "Asia/Yakutsk" + ASIA_YANGON = "Asia/Yangon" + ASIA_YEKATERINBURG = "Asia/Yekaterinburg" + ASIA_YEREVAN = "Asia/Yerevan" + ATLANTIC_AZORES = "Atlantic/Azores" + ATLANTIC_BERMUDA = "Atlantic/Bermuda" + ATLANTIC_CANARY = "Atlantic/Canary" + ATLANTIC_CAPE_VERDE = "Atlantic/Cape_Verde" + ATLANTIC_FAROE = "Atlantic/Faroe" + ATLANTIC_MADEIRA = "Atlantic/Madeira" + ATLANTIC_REYKJAVIK = "Atlantic/Reykjavik" + ATLANTIC_SOUTH_GEORGIA = "Atlantic/South_Georgia" + ATLANTIC_ST_HELENA = "Atlantic/St_Helena" + ATLANTIC_STANLEY = "Atlantic/Stanley" + AUSTRALIA_ADELAIDE = "Australia/Adelaide" + AUSTRALIA_BRISBANE = "Australia/Brisbane" + AUSTRALIA_BROKEN_HILL = "Australia/Broken_Hill" + AUSTRALIA_CURRIE = "Australia/Currie" + AUSTRALIA_DARWIN = "Australia/Darwin" + AUSTRALIA_EUCLA = "Australia/Eucla" + AUSTRALIA_HOBART = "Australia/Hobart" + AUSTRALIA_LINDEMAN = "Australia/Lindeman" + AUSTRALIA_LORD_HOWE = "Australia/Lord_Howe" + AUSTRALIA_MELBOURNE = "Australia/Melbourne" + AUSTRALIA_PERTH = "Australia/Perth" + AUSTRALIA_SYDNEY = "Australia/Sydney" + EUROPE_AMSTERDAM = "Europe/Amsterdam" + EUROPE_ANDORRA = "Europe/Andorra" + EUROPE_ASTRAKHAN = "Europe/Astrakhan" + EUROPE_ATHENS = "Europe/Athens" + EUROPE_BELGRADE = "Europe/Belgrade" + EUROPE_BERLIN = "Europe/Berlin" + EUROPE_BRATISLAVA = "Europe/Bratislava" + EUROPE_BRUSSELS = "Europe/Brussels" + EUROPE_BUCHAREST = "Europe/Bucharest" + EUROPE_BUDAPEST = "Europe/Budapest" + EUROPE_BUSINGEN = "Europe/Busingen" + EUROPE_CHISINAU = "Europe/Chisinau" + EUROPE_COPENHAGEN = "Europe/Copenhagen" + EUROPE_DUBLIN = "Europe/Dublin" + EUROPE_GIBRALTAR = "Europe/Gibraltar" + EUROPE_GUERNSEY = "Europe/Guernsey" + EUROPE_HELSINKI = "Europe/Helsinki" + EUROPE_ISLE_OF_MAN = "Europe/Isle_of_Man" + EUROPE_ISTANBUL = "Europe/Istanbul" + EUROPE_JERSEY = "Europe/Jersey" + EUROPE_KALININGRAD = "Europe/Kaliningrad" + EUROPE_KIEV = "Europe/Kiev" + EUROPE_KIROV = "Europe/Kirov" + EUROPE_LISBON = "Europe/Lisbon" + EUROPE_LJUBLJANA = "Europe/Ljubljana" + EUROPE_LONDON = "Europe/London" + EUROPE_LUXEMBOURG = "Europe/Luxembourg" + EUROPE_MADRID = "Europe/Madrid" + EUROPE_MALTA = "Europe/Malta" + EUROPE_MARIEHAMN = "Europe/Mariehamn" + EUROPE_MINSK = "Europe/Minsk" + EUROPE_MONACO = "Europe/Monaco" + EUROPE_MOSCOW = "Europe/Moscow" + EUROPE_NICOSIA = "Europe/Nicosia" + EUROPE_OSLO = "Europe/Oslo" + EUROPE_PARIS = "Europe/Paris" + EUROPE_PODGORICA = "Europe/Podgorica" + EUROPE_PRAGUE = "Europe/Prague" + EUROPE_RIGA = "Europe/Riga" + EUROPE_ROME = "Europe/Rome" + EUROPE_SAMARA = "Europe/Samara" + EUROPE_SAN_MARINO = "Europe/San_Marino" + EUROPE_SARAJEVO = "Europe/Sarajevo" + EUROPE_SARATOV = "Europe/Saratov" + EUROPE_SIMFEROPOL = "Europe/Simferopol" + EUROPE_SKOPJE = "Europe/Skopje" + EUROPE_SOFIA = "Europe/Sofia" + EUROPE_STOCKHOLM = "Europe/Stockholm" + EUROPE_TALLINN = "Europe/Tallinn" + EUROPE_TIRANE = "Europe/Tirane" + EUROPE_ULYANOVSK = "Europe/Ulyanovsk" + EUROPE_UZHGOROD = "Europe/Uzhgorod" + EUROPE_VADUZ = "Europe/Vaduz" + EUROPE_VATICAN = "Europe/Vatican" + EUROPE_VIENNA = "Europe/Vienna" + EUROPE_VILNIUS = "Europe/Vilnius" + EUROPE_VOLGOGRAD = "Europe/Volgograd" + EUROPE_WARSAW = "Europe/Warsaw" + EUROPE_ZAGREB = "Europe/Zagreb" + EUROPE_ZAPOROZHYE = "Europe/Zaporozhye" + EUROPE_ZURICH = "Europe/Zurich" + INDIAN_ANTANANARIVO = "Indian/Antananarivo" + INDIAN_CHAGOS = "Indian/Chagos" + INDIAN_CHRISTMAS = "Indian/Christmas" + INDIAN_COCOS = "Indian/Cocos" + INDIAN_COMORO = "Indian/Comoro" + INDIAN_KERGUELEN = "Indian/Kerguelen" + INDIAN_MAHE = "Indian/Mahe" + INDIAN_MALDIVES = "Indian/Maldives" + INDIAN_MAURITIUS = "Indian/Mauritius" + INDIAN_MAYOTTE = "Indian/Mayotte" + INDIAN_REUNION = "Indian/Reunion" + PACIFIC_APIA = "Pacific/Apia" + PACIFIC_AUCKLAND = "Pacific/Auckland" + PACIFIC_BOUGAINVILLE = "Pacific/Bougainville" + PACIFIC_CHATHAM = "Pacific/Chatham" + PACIFIC_CHUUK = "Pacific/Chuuk" + PACIFIC_EASTER = "Pacific/Easter" + PACIFIC_EFATE = "Pacific/Efate" + PACIFIC_ENDERBURY = "Pacific/Enderbury" + PACIFIC_FAKAOFO = "Pacific/Fakaofo" + PACIFIC_FIJI = "Pacific/Fiji" + PACIFIC_FUNAFUTI = "Pacific/Funafuti" + PACIFIC_GALAPAGOS = "Pacific/Galapagos" + PACIFIC_GAMBIER = "Pacific/Gambier" + PACIFIC_GUADALCANAL = "Pacific/Guadalcanal" + PACIFIC_GUAM = "Pacific/Guam" + PACIFIC_HONOLULU = "Pacific/Honolulu" + PACIFIC_KANTON = "Pacific/Kanton" + PACIFIC_KIRITIMATI = "Pacific/Kiritimati" + PACIFIC_KOSRAE = "Pacific/Kosrae" + PACIFIC_KWAJALEIN = "Pacific/Kwajalein" + PACIFIC_MAJURO = "Pacific/Majuro" + PACIFIC_MARQUESAS = "Pacific/Marquesas" + PACIFIC_MIDWAY = "Pacific/Midway" + PACIFIC_NAURU = "Pacific/Nauru" + PACIFIC_NIUE = "Pacific/Niue" + PACIFIC_NORFOLK = "Pacific/Norfolk" + PACIFIC_NOUMEA = "Pacific/Noumea" + PACIFIC_PAGO_PAGO = "Pacific/Pago_Pago" + PACIFIC_PALAU = "Pacific/Palau" + PACIFIC_PITCAIRN = "Pacific/Pitcairn" + PACIFIC_POHNPEI = "Pacific/Pohnpei" + PACIFIC_PORT_MORESBY = "Pacific/Port_Moresby" + PACIFIC_RAROTONGA = "Pacific/Rarotonga" + PACIFIC_SAIPAN = "Pacific/Saipan" + PACIFIC_TAHITI = "Pacific/Tahiti" + PACIFIC_TARAWA = "Pacific/Tarawa" + PACIFIC_TONGATAPU = "Pacific/Tongatapu" + PACIFIC_WAKE = "Pacific/Wake" + PACIFIC_WALLIS = "Pacific/Wallis" + + +################################################################# +# Schema Management (Tables and Fields) +# NOTE: No delete operations are available in the Airtable API +################################################################# + + +async def create_table( + credentials: Credentials, + base_id: str, + table_name: str, + table_fields: list[dict], +) -> dict: + for field in table_fields: + assert field.get("name"), "Field name is required" + assert ( + field.get("type") in TABLE_FIELD_TYPES + ), f"Field type {field.get('type')} is not valid. Valid types are {TABLE_FIELD_TYPES}." + # Note fields have differnet options for different types we are not currently validating them + + response = await Requests().post( + f"https://api.airtable.com/v0/meta/bases/{base_id}/tables", + headers={"Authorization": credentials.auth_header()}, + json={ + "name": table_name, + "fields": table_fields, + }, + ) + + return response.json() + + +async def update_table( + credentials: Credentials, + base_id: str, + table_id: str, + table_name: str | None = None, + table_description: str | None = None, + date_dependency: dict | None = None, +) -> dict: + + assert ( + table_name or table_description or date_dependency + ), "At least one of table_name, table_description, or date_dependency must be provided" + + params: dict[str, str | dict[str, str]] = {} + if table_name: + params["name"] = table_name + if table_description: + params["description"] = table_description + if date_dependency: + params["dateDependency"] = date_dependency + + response = await Requests().patch( + f"https://api.airtable.com/v0/meta/bases/{base_id}/tables/{table_id}", + headers={"Authorization": credentials.auth_header()}, + json=_convert_bools(params), + ) + + return response.json() + + +async def create_field( + credentials: Credentials, + base_id: str, + table_id: str, + field_type: TableFieldType, + name: str, + description: str | None = None, + options: dict[str, str] | None = None, +) -> dict[str, str | dict[str, str]]: + + assert ( + field_type in TABLE_FIELD_TYPES + ), f"Field type {field_type} is not valid. Valid types are {TABLE_FIELD_TYPES}." + params: dict[str, str | dict[str, str]] = {} + params["type"] = field_type + params["name"] = name + if description: + params["description"] = description + if options: + params["options"] = options + + response = await Requests().post( + f"https://api.airtable.com/v0/meta/bases/{base_id}/tables/{table_id}/fields", + headers={"Authorization": credentials.auth_header()}, + json=_convert_bools(params), + ) + return response.json() + + +async def update_field( + credentials: Credentials, + base_id: str, + table_id: str, + field_id: str, + name: str | None = None, + description: str | None = None, +) -> dict[str, str]: + + assert name or description, "At least one of name or description must be provided" + params: dict[str, str | dict[str, str]] = {} + if name: + params["name"] = name + if description: + params["description"] = description + + response = await Requests().patch( + f"https://api.airtable.com/v0/meta/bases/{base_id}/tables/{table_id}/fields/{field_id}", + headers={"Authorization": credentials.auth_header()}, + json=_convert_bools(params), + ) + return response.json() + + +################################################################# +# Record Management +################################################################# + + +async def list_records( + credentials: Credentials, + base_id: str, + table_id_or_name: str, + # Query parameters + time_zone: AirtableTimeZones | None = None, + user_local: str | None = None, + page_size: int | None = None, + max_records: int | None = None, + offset: str | None = None, + view: str | None = None, + sort: list[dict[str, str]] | None = None, + filter_by_formula: str | None = None, + cell_format: dict[str, str] | None = None, + fields: list[str] | None = None, + return_fields_by_field_id: bool | None = None, + record_metadata: list[str] | None = None, +) -> dict[str, list[dict[str, dict[str, str]]]]: + + params: dict[str, str | dict[str, str] | list[dict[str, str]] | list[str]] = {} + if time_zone: + params["timeZone"] = time_zone + if user_local: + params["userLocal"] = user_local + if page_size: + params["pageSize"] = str(page_size) + if max_records: + params["maxRecords"] = str(max_records) + if offset: + params["offset"] = offset + if view: + params["view"] = view + if sort: + params["sort"] = sort + if filter_by_formula: + params["filterByFormula"] = filter_by_formula + if cell_format: + params["cellFormat"] = cell_format + if fields: + params["fields"] = fields + if return_fields_by_field_id: + params["returnFieldsByFieldId"] = str(return_fields_by_field_id) + if record_metadata: + params["recordMetadata"] = record_metadata + + response = await Requests().get( + f"https://api.airtable.com/v0/{base_id}/{table_id_or_name}", + headers={"Authorization": credentials.auth_header()}, + json=_convert_bools(params), + ) + return response.json() + + +async def get_record( + credentials: Credentials, + base_id: str, + table_id_or_name: str, + record_id: str, +) -> dict[str, dict[str, dict[str, str]]]: + + response = await Requests().get( + f"https://api.airtable.com/v0/{base_id}/{table_id_or_name}/{record_id}", + headers={"Authorization": credentials.auth_header()}, + ) + return response.json() + + +async def update_multiple_records( + credentials: Credentials, + base_id: str, + table_id_or_name: str, + records: list[dict[str, dict[str, str]]], + perform_upsert: dict[str, list[str]] | None = None, + return_fields_by_field_id: bool | None = None, + typecast: bool | None = None, +) -> dict[str, dict[str, dict[str, str]]]: + + params: dict[ + str, str | bool | dict[str, list[str]] | list[dict[str, dict[str, str]]] + ] = {} + if perform_upsert: + params["performUpsert"] = perform_upsert + if return_fields_by_field_id: + params["returnFieldsByFieldId"] = str(return_fields_by_field_id) + if typecast: + params["typecast"] = typecast + + params["records"] = [_convert_bools(record) for record in records] + + response = await Requests().patch( + f"https://api.airtable.com/v0/{base_id}/{table_id_or_name}", + headers={"Authorization": credentials.auth_header()}, + json=_convert_bools(params), + ) + return response.json() + + +async def update_record( + credentials: Credentials, + base_id: str, + table_id_or_name: str, + record_id: str, + return_fields_by_field_id: bool | None = None, + typecast: bool | None = None, + fields: dict[str, Any] | None = None, +) -> dict[str, dict[str, dict[str, str]]]: + params: dict[str, str | bool | dict[str, Any] | list[dict[str, dict[str, str]]]] = ( + {} + ) + if return_fields_by_field_id: + params["returnFieldsByFieldId"] = return_fields_by_field_id + if typecast: + params["typecast"] = typecast + if fields: + params["fields"] = fields + + response = await Requests().patch( + f"https://api.airtable.com/v0/{base_id}/{table_id_or_name}/{record_id}", + headers={"Authorization": credentials.auth_header()}, + json=_convert_bools(params), + ) + return response.json() + + +async def create_record( + credentials: Credentials, + base_id: str, + table_id_or_name: str, + fields: dict[str, Any] | None = None, + records: list[dict[str, Any]] | None = None, + return_fields_by_field_id: bool | None = None, + typecast: bool | None = None, +) -> dict[str, dict[str, dict[str, str]]]: + assert fields or records, "At least one of fields or records must be provided" + assert not (fields and records), "Only one of fields or records can be provided" + if records is not None: + assert ( + len(records) <= 10 + ), "Only up to 10 records can be provided when using records" + + params: dict[str, str | bool | dict[str, Any] | list[dict[str, Any]]] = {} + if fields: + params["fields"] = fields + if records: + params["records"] = records + if return_fields_by_field_id: + params["returnFieldsByFieldId"] = return_fields_by_field_id + if typecast: + params["typecast"] = typecast + + response = await Requests().post( + f"https://api.airtable.com/v0/{base_id}/{table_id_or_name}", + headers={"Authorization": credentials.auth_header()}, + json=_convert_bools(params), + ) + + return response.json() + + +async def delete_multiple_records( + credentials: Credentials, + base_id: str, + table_id_or_name: str, + records: list[str], +) -> dict[str, dict[str, dict[str, str]]]: + + query_string = "&".join([f"records[]={quote(record)}" for record in records]) + response = await Requests().delete( + f"https://api.airtable.com/v0/{base_id}/{table_id_or_name}?{query_string}", + headers={"Authorization": credentials.auth_header()}, + ) + return response.json() + + +async def delete_record( + credentials: Credentials, + base_id: str, + table_id_or_name: str, + record_id: str, +) -> dict[str, dict[str, dict[str, str]]]: + + response = await Requests().delete( + f"https://api.airtable.com/v0/{base_id}/{table_id_or_name}/{record_id}", + headers={"Authorization": credentials.auth_header()}, + ) + return response.json() + + +async def create_webhook( + credentials: Credentials, + base_id: str, + webhook_specification: WebhookSpecification, + notification_url: str | None = None, +) -> Any: + + params: dict[str, Any] = { + "specification": { + "options": { + "filters": webhook_specification.filters.model_dump(exclude_unset=True), + } + }, + } + if webhook_specification.includes: + params["specification"]["options"]["includes"] = ( + webhook_specification.includes.model_dump(exclude_unset=True) + ) + if notification_url: + params["notificationUrl"] = notification_url + + response = await Requests().post( + f"https://api.airtable.com/v0/bases/{base_id}/webhooks", + headers={"Authorization": credentials.auth_header()}, + json=_convert_bools(params), + ) + return response.json() + + +async def delete_webhook( + credentials: Credentials, + base_id: str, + webhook_id: str, +) -> Any: + + response = await Requests().delete( + f"https://api.airtable.com/v0/bases/{base_id}/webhooks/{webhook_id}", + headers={"Authorization": credentials.auth_header()}, + ) + return response.json() + + +async def list_webhook_payloads( + credentials: Credentials, + base_id: str, + webhook_id: str, + cursor: str | None = None, + limit: int | None = None, +) -> ListWebhookPayloadsResponse: + + query_string = "" + if cursor: + query_string += f"cursor={cursor}" + if limit: + query_string += f"limit={limit}" + + if query_string: + query_string = f"?{query_string}" + + response = await Requests().get( + f"https://api.airtable.com/v0/bases/{base_id}/webhooks/{webhook_id}/payloads{query_string}", + headers={"Authorization": credentials.auth_header()}, + ) + try: + logger.info(f"Response: {response.json()}") + return ListWebhookPayloadsResponse( + payloads=response.json().get("payloads", []), + cursor=response.json().get("cursor"), + might_have_more=response.json().get("might_have_more") == "True", + payloadFormat=response.json().get("payloadFormat", "v0"), + ) + except Exception as e: + raise ValueError( + f"Failed to validate webhook payloads response: {e}\nResponse: {response.json()}" + ) + + +async def list_webhooks( + credentials: Credentials, + base_id: str, +) -> Any: + + response = await Requests().get( + f"https://api.airtable.com/v0/bases/{base_id}/webhooks", + headers={"Authorization": credentials.auth_header()}, + ) + return response.json() + + +class OAuthAuthorizeRequest(BaseModel): + """OAuth authorization request parameters for Airtable. + + Parameters: + client_id: An opaque string that identifies your integration with Airtable + redirect_uri: The URI for the authorize response redirect. Must exactly match a redirect URI + associated with your integration. HTTPS is required for any URI beside localhost. + response_type: The string "code" + scope: A space delimited list of unique scopes. All scopes must be valid Airtable defined scopes + that have been selected for your integration. At least one scope is required. + state: A cryptographically generated, opaque string for CSRF protection + code_challenge: The base64 url-encoding of the sha256 of the code_verifier. Protects against + man-in-the-middle grant code injection attacks. Part of the PKCE extension of OAuth. + code_challenge_method: The string "S256" + """ + + client_id: str + redirect_uri: str + response_type: str = "code" + scope: str + state: str + code_challenge: str + code_challenge_method: str = "S256" + + +class OAuthTokenRequest(BaseModel): + """OAuth token request parameters for Airtable. + + These parameters must be formatted via application/x-www-form-urlencoded encoding. + + Parameters: + code: The grant code generated during the authorization request. Can only be used once. + client_id: The client_id used in the authorization request that generated the code. + Optional if your integration has a client_secret. Used to prevent MITM attacks. + redirect_uri: The redirect_uri used in the authorization request that generated the code. + Used to prevent MITM attacks. + grant_type: The string "authorization_code". + code_verifier: A cryptographically generated, opaque string used to generate the + code_challenge parameter in the authorization request that generated the code. + """ + + code: str + client_id: str + redirect_uri: str + grant_type: str = "authorization_code" + code_verifier: str + + +class OAuthRefreshTokenRequest(BaseModel): + """OAuth token refresh request parameters for Airtable. + + These parameters must be formatted via application/x-www-form-urlencoded encoding. + + Parameters: + refresh_token: The saved refresh token from the previous token grant. + client_id: Required if your integration does not have a client_secret. + Used to prevent MITM attacks. + grant_type: The string "refresh_token". + scope: If specified, a subset of the token's existing scopes. Optional. + """ + + refresh_token: str + client_id: str | None = None + grant_type: str = "refresh_token" + scope: str | None = None + + +class OAuthTokenResponse(BaseModel): + """OAuth token response from Airtable. + + Successful response has HTTP status code 200 (OK). + + Parameters: + access_token: An opaque string. Can be used to make requests to the Airtable API on behalf + of the user, and cannot be recovered if lost. + refresh_token: An opaque string. Can be used to request a new access token after the current + one expires. + token_type: The string "Bearer " (space intentional) + scope: A string that is a space delimited list of scopes granted to this access token. Can be + recovered using the get userId and scopes endpoint. + expires_in: An integer. Time in seconds until the access token expires (expected value is 60 minutes). + refresh_expires_in: An integer. Time in seconds until the refresh token expires (expected value is 60 days). + """ + + access_token: str + refresh_token: str + token_type: str + scope: str + expires_in: int + refresh_expires_in: int + + +def make_oauth_authorize_url( + client_id: str, + redirect_uri: str, + scopes: list[str], + state: str, + code_challenge: str, +) -> str: + """ + Generate the OAuth authorization URL for Airtable. + + Args: + client_id: An opaque string that identifies your integration with Airtable + redirect_uri: The URI for the authorize response redirect + scope: A space delimited list of unique scopes + state: A cryptographically generated, opaque string for CSRF protection + code_challenge: The base64 url-encoding of the sha256 of the code_verifier + code_challenge_method: The string "S256" (default) + response_type: The string "code" (default) + + Returns: + The authorization URL that the user should visit + """ + # Validate the request parameters + request_params = OAuthAuthorizeRequest( + client_id=client_id, + redirect_uri=redirect_uri, + scope=" ".join(scopes), + state=state, + code_challenge=code_challenge, + ) + + # Build the authorization URL + base_url = "https://airtable.com/oauth2/v1/authorize" + query_string = urlencode(request_params.model_dump(exclude_none=True)) + + return f"{base_url}?{query_string}" + + +async def oauth_exchange_code_for_tokens( + client_id: str, + code_verifier: bytes, + code: str, + redirect_uri: str, + client_secret: str | None = None, +) -> OAuthTokenResponse: + """ + Exchange an authorization code for access and refresh tokens. + + Args: + client_id: The Airtable integration client ID. + code_verifier: The original code_verifier (required for PKCE). + code: The authorization code returned by Airtable. + redirect_uri: The redirect URI used during authorization. + client_secret: Integration client secret if available (optional). + + Returns: + Parsed JSON response containing the access token, refresh token, scope, etc. + """ + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + # Add Authorization header for confidential clients + if client_secret: + credentials_encoded = base64.urlsafe_b64encode( + f"{client_id}:{client_secret}".encode() + ).decode() + headers["Authorization"] = f"Basic {credentials_encoded}" + + data = OAuthTokenRequest( + code=code, + client_id=client_id, + redirect_uri=redirect_uri, + grant_type="authorization_code", + code_verifier=code_verifier.decode("utf-8"), + ).model_dump(exclude_none=True) + + response = await Requests().post( + "https://airtable.com/oauth2/v1/token", + headers=headers, + data=data, + ) + + if response.ok: + return OAuthTokenResponse.model_validate(response.json()) + raise ValueError( + f"Failed to exchange code for tokens: {response.status} {response.text}" + ) + + +# NEW helper for refreshing tokens +async def oauth_refresh_tokens( + client_id: str, + refresh_token: str, + client_secret: str | None = None, +) -> OAuthTokenResponse: + """ + Refresh an expired (or soon-to-expire) access token. + + Args: + client_id: The Airtable integration client ID. + refresh_token: The refresh token previously issued by Airtable. + client_secret: Integration client secret if available (optional). + + Returns: + Parsed JSON response containing the new tokens and metadata. + https://airtable.com/oauth2/v1/authorize?client_id=7642abbb-8fbc-494c-b6e0-58484364e28c&redirect_uri=https%3A%2F%2Fdev-builder.agpt.co%2Fauth%2Fintegrations%2Foauth_callback&response_type=code&scope=&state=OcmqX6Y5MTkhHLc6vkbR6uEtSiZHawzEUcxDscqkWRk&code_challenge=v2Ly1CcG8UkCXJ2n--TEKZc6HeKaN1wrZLgIr_qVnJ8&code_challenge_method=S256 + """ + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + + if client_secret: + credentials_encoded = base64.urlsafe_b64encode( + f"{client_id}:{client_secret}".encode() + ).decode() + headers["Authorization"] = f"Basic {credentials_encoded}" + + data = OAuthRefreshTokenRequest( + refresh_token=refresh_token, + client_id=client_id, + grant_type="refresh_token", + ).model_dump(exclude_none=True) + + response = await Requests().post( + "https://airtable.com/oauth2/v1/token", + headers=headers, + data=data, + ) + + if response.ok: + return OAuthTokenResponse.model_validate(response.json()) + raise ValueError(f"Failed to refresh tokens: {response.status} {response.text}") + + +################################################################# +# Base Management +################################################################# + + +async def create_base( + credentials: Credentials, + workspace_id: str, + name: str, + tables: list[dict] = [ + { + "description": "Default table", + "name": "Default table", + "fields": [ + { + "name": "ID", + "type": "number", + "description": "Auto-incrementing ID field", + "options": {"precision": 0}, + } + ], + } + ], +) -> dict: + """ + Create a new base in Airtable. + + Args: + credentials: Airtable API credentials + workspace_id: The workspace ID where the base will be created + name: The name of the new base + tables: Optional list of table objects to create in the base + + Returns: + dict: Response containing the created base information + """ + params: dict[str, Any] = { + "name": name, + "workspaceId": workspace_id, + } + + if tables: + params["tables"] = tables + + print(params) + + response = await Requests().post( + "https://api.airtable.com/v0/meta/bases", + headers={ + "Authorization": credentials.auth_header(), + "Content-Type": "application/json", + }, + json=_convert_bools(params), + ) + + return response.json() + + +async def list_bases( + credentials: Credentials, + offset: str | None = None, +) -> dict: + """ + List all bases that the authenticated user has access to. + + Args: + credentials: Airtable API credentials + offset: Optional pagination offset + + Returns: + dict: Response containing the list of bases + """ + params = {} + if offset: + params["offset"] = offset + + response = await Requests().get( + "https://api.airtable.com/v0/meta/bases", + headers={"Authorization": credentials.auth_header()}, + params=params, + ) + + return response.json() diff --git a/autogpt_platform/backend/backend/blocks/airtable/_api_test.py b/autogpt_platform/backend/backend/blocks/airtable/_api_test.py new file mode 100644 index 000000000000..02f15a509fa1 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/airtable/_api_test.py @@ -0,0 +1,323 @@ +from os import getenv +from uuid import uuid4 + +import pytest + +from backend.sdk import APIKeyCredentials, SecretStr + +from ._api import ( + TableFieldType, + WebhookFilters, + WebhookSpecification, + create_base, + create_field, + create_record, + create_table, + create_webhook, + delete_multiple_records, + delete_record, + delete_webhook, + get_record, + list_bases, + list_records, + list_webhook_payloads, + update_field, + update_multiple_records, + update_record, + update_table, +) + + +@pytest.mark.asyncio +async def test_create_update_table(): + + key = getenv("AIRTABLE_API_KEY") + if not key: + return pytest.skip("AIRTABLE_API_KEY is not set") + + credentials = APIKeyCredentials( + provider="airtable", + api_key=SecretStr(key), + ) + postfix = uuid4().hex[:4] + workspace_id = "wsphuHmfllg7V3Brd" + response = await create_base(credentials, workspace_id, "API Testing Base") + assert response is not None, f"Checking create base response: {response}" + assert ( + response.get("id") is not None + ), f"Checking create base response id: {response}" + base_id = response.get("id") + assert base_id is not None, f"Checking create base response id: {base_id}" + + response = await list_bases(credentials) + assert response is not None, f"Checking list bases response: {response}" + assert "API Testing Base" in [ + base.get("name") for base in response.get("bases", []) + ], f"Checking list bases response bases: {response}" + + table_name = f"test_table_{postfix}" + table_fields = [{"name": "test_field", "type": "singleLineText"}] + table = await create_table(credentials, base_id, table_name, table_fields) + assert table.get("name") == table_name + + table_id = table.get("id") + + assert table_id is not None + + table_name = f"test_table_updated_{postfix}" + table_description = "test_description_updated" + table = await update_table( + credentials, + base_id, + table_id, + table_name=table_name, + table_description=table_description, + ) + assert table.get("name") == table_name + assert table.get("description") == table_description + + +@pytest.mark.asyncio +async def test_invalid_field_type(): + + key = getenv("AIRTABLE_API_KEY") + if not key: + return pytest.skip("AIRTABLE_API_KEY is not set") + + credentials = APIKeyCredentials( + provider="airtable", + api_key=SecretStr(key), + ) + postfix = uuid4().hex[:4] + base_id = "appZPxegHEU3kDc1S" + table_name = f"test_table_{postfix}" + table_fields = [{"name": "test_field", "type": "notValid"}] + with pytest.raises(AssertionError): + await create_table(credentials, base_id, table_name, table_fields) + + +@pytest.mark.asyncio +async def test_create_and_update_field(): + key = getenv("AIRTABLE_API_KEY") + if not key: + return pytest.skip("AIRTABLE_API_KEY is not set") + + credentials = APIKeyCredentials( + provider="airtable", + api_key=SecretStr(key), + ) + postfix = uuid4().hex[:4] + base_id = "appZPxegHEU3kDc1S" + table_name = f"test_table_{postfix}" + table_fields = [{"name": "test_field", "type": "singleLineText"}] + table = await create_table(credentials, base_id, table_name, table_fields) + assert table.get("name") == table_name + + table_id = table.get("id") + + assert table_id is not None + + field_name = f"test_field_{postfix}" + field_type = TableFieldType.SINGLE_LINE_TEXT + field = await create_field(credentials, base_id, table_id, field_type, field_name) + assert field.get("name") == field_name + + field_id = field.get("id") + + assert field_id is not None + assert isinstance(field_id, str) + + field_name = f"test_field_updated_{postfix}" + field = await update_field(credentials, base_id, table_id, field_id, field_name) + assert field.get("name") == field_name + + field_description = "test_description_updated" + field = await update_field( + credentials, base_id, table_id, field_id, description=field_description + ) + assert field.get("description") == field_description + + +@pytest.mark.asyncio +async def test_record_management(): + key = getenv("AIRTABLE_API_KEY") + if not key: + return pytest.skip("AIRTABLE_API_KEY is not set") + + credentials = APIKeyCredentials( + provider="airtable", + api_key=SecretStr(key), + ) + postfix = uuid4().hex[:4] + base_id = "appZPxegHEU3kDc1S" + table_name = f"test_table_{postfix}" + table_fields = [{"name": "test_field", "type": "singleLineText"}] + table = await create_table(credentials, base_id, table_name, table_fields) + assert table.get("name") == table_name + + table_id = table.get("id") + assert table_id is not None + + # Create a record + record_fields = {"test_field": "test_value"} + record = await create_record(credentials, base_id, table_id, fields=record_fields) + fields = record.get("fields") + assert fields is not None + assert isinstance(fields, dict) + assert fields.get("test_field") == "test_value" + + record_id = record.get("id") + + assert record_id is not None + assert isinstance(record_id, str) + + # Get a record + record = await get_record(credentials, base_id, table_id, record_id) + fields = record.get("fields") + assert fields is not None + assert isinstance(fields, dict) + assert fields.get("test_field") == "test_value" + + # Updata a record + record_fields = {"test_field": "test_value_updated"} + record = await update_record( + credentials, base_id, table_id, record_id, fields=record_fields + ) + fields = record.get("fields") + assert fields is not None + assert isinstance(fields, dict) + assert fields.get("test_field") == "test_value_updated" + + # Delete a record + record = await delete_record(credentials, base_id, table_id, record_id) + assert record is not None + assert record.get("id") == record_id + assert record.get("deleted") + + # Create 2 records + records = [ + {"fields": {"test_field": "test_value_1"}}, + {"fields": {"test_field": "test_value_2"}}, + ] + response = await create_record(credentials, base_id, table_id, records=records) + created_records = response.get("records") + assert created_records is not None + assert isinstance(created_records, list) + assert len(created_records) == 2, f"Created records: {created_records}" + first_record = created_records[0] # type: ignore + second_record = created_records[1] # type: ignore + first_record_id = first_record.get("id") + second_record_id = second_record.get("id") + assert first_record_id is not None + assert second_record_id is not None + assert first_record_id != second_record_id + first_fields = first_record.get("fields") + second_fields = second_record.get("fields") + assert first_fields is not None + assert second_fields is not None + assert first_fields.get("test_field") == "test_value_1" # type: ignore + assert second_fields.get("test_field") == "test_value_2" # type: ignore + + # List records + response = await list_records(credentials, base_id, table_id) + records = response.get("records") + assert records is not None + assert len(records) == 2, f"Records: {records}" + assert isinstance(records, list), f"Type of records: {type(records)}" + + # Update multiple records + records = [ + {"id": first_record_id, "fields": {"test_field": "test_value_1_updated"}}, + {"id": second_record_id, "fields": {"test_field": "test_value_2_updated"}}, + ] + response = await update_multiple_records( + credentials, base_id, table_id, records=records + ) + updated_records = response.get("records") + assert updated_records is not None + assert len(updated_records) == 2, f"Updated records: {updated_records}" + assert isinstance( + updated_records, list + ), f"Type of updated records: {type(updated_records)}" + first_updated = updated_records[0] # type: ignore + second_updated = updated_records[1] # type: ignore + first_updated_fields = first_updated.get("fields") + second_updated_fields = second_updated.get("fields") + assert first_updated_fields is not None + assert second_updated_fields is not None + assert first_updated_fields.get("test_field") == "test_value_1_updated" # type: ignore + assert second_updated_fields.get("test_field") == "test_value_2_updated" # type: ignore + + # Delete multiple records + assert isinstance(first_record_id, str) + assert isinstance(second_record_id, str) + response = await delete_multiple_records( + credentials, base_id, table_id, records=[first_record_id, second_record_id] + ) + deleted_records = response.get("records") + assert deleted_records is not None + assert len(deleted_records) == 2, f"Deleted records: {deleted_records}" + assert isinstance( + deleted_records, list + ), f"Type of deleted records: {type(deleted_records)}" + first_deleted = deleted_records[0] # type: ignore + second_deleted = deleted_records[1] # type: ignore + assert first_deleted.get("deleted") + assert second_deleted.get("deleted") + + +@pytest.mark.asyncio +async def test_webhook_management(): + key = getenv("AIRTABLE_API_KEY") + if not key: + return pytest.skip("AIRTABLE_API_KEY is not set") + + credentials = APIKeyCredentials( + provider="airtable", + api_key=SecretStr(key), + ) + postfix = uuid4().hex[:4] + base_id = "appZPxegHEU3kDc1S" + table_name = f"test_table_{postfix}" + table_fields = [{"name": "test_field", "type": "singleLineText"}] + table = await create_table(credentials, base_id, table_name, table_fields) + assert table.get("name") == table_name + + table_id = table.get("id") + assert table_id is not None + webhook_specification = WebhookSpecification( + filters=WebhookFilters( + dataTypes=["tableData", "tableFields", "tableMetadata"], + changeTypes=["add", "update", "remove"], + ) + ) + response = await create_webhook(credentials, base_id, webhook_specification) + assert response is not None, f"Checking create webhook response: {response}" + assert ( + response.get("id") is not None + ), f"Checking create webhook response id: {response}" + assert ( + response.get("macSecretBase64") is not None + ), f"Checking create webhook response macSecretBase64: {response}" + + webhook_id = response.get("id") + assert webhook_id is not None, f"Webhook ID: {webhook_id}" + assert isinstance(webhook_id, str) + + response = await create_record( + credentials, base_id, table_id, fields={"test_field": "test_value"} + ) + assert response is not None, f"Checking create record response: {response}" + assert ( + response.get("id") is not None + ), f"Checking create record response id: {response}" + fields = response.get("fields") + assert fields is not None, f"Checking create record response fields: {response}" + assert ( + fields.get("test_field") == "test_value" + ), f"Checking create record response fields test_field: {response}" + + response = await list_webhook_payloads(credentials, base_id, webhook_id) + assert response is not None, f"Checking list webhook payloads response: {response}" + + response = await delete_webhook(credentials, base_id, webhook_id) diff --git a/autogpt_platform/backend/backend/blocks/airtable/_config.py b/autogpt_platform/backend/backend/blocks/airtable/_config.py new file mode 100644 index 000000000000..ee168881d8f7 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/airtable/_config.py @@ -0,0 +1,32 @@ +""" +Shared configuration for all Airtable blocks using the SDK pattern. +""" + +from backend.sdk import BlockCostType, ProviderBuilder + +from ._oauth import AirtableOAuthHandler, AirtableScope +from ._webhook import AirtableWebhookManager + +# Configure the Airtable provider with API key authentication +airtable = ( + ProviderBuilder("airtable") + .with_api_key("AIRTABLE_API_KEY", "Airtable Personal Access Token") + .with_webhook_manager(AirtableWebhookManager) + .with_base_cost(1, BlockCostType.RUN) + .with_oauth( + AirtableOAuthHandler, + scopes=[ + v.value + for v in [ + AirtableScope.DATA_RECORDS_READ, + AirtableScope.DATA_RECORDS_WRITE, + AirtableScope.SCHEMA_BASES_READ, + AirtableScope.SCHEMA_BASES_WRITE, + AirtableScope.WEBHOOK_MANAGE, + ] + ], + client_id_env_var="AIRTABLE_CLIENT_ID", + client_secret_env_var="AIRTABLE_CLIENT_SECRET", + ) + .build() +) diff --git a/autogpt_platform/backend/backend/blocks/airtable/_oauth.py b/autogpt_platform/backend/backend/blocks/airtable/_oauth.py new file mode 100644 index 000000000000..9cd69ec3defe --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/airtable/_oauth.py @@ -0,0 +1,185 @@ +""" +Airtable OAuth handler implementation. +""" + +import time +from enum import Enum +from logging import getLogger +from typing import Optional + +from backend.sdk import BaseOAuthHandler, OAuth2Credentials, ProviderName, SecretStr + +from ._api import ( + OAuthTokenResponse, + make_oauth_authorize_url, + oauth_exchange_code_for_tokens, + oauth_refresh_tokens, +) + +logger = getLogger(__name__) + + +class AirtableScope(str, Enum): + # Basic scopes + DATA_RECORDS_READ = "data.records:read" + DATA_RECORDS_WRITE = "data.records:write" + DATA_RECORD_COMMENTS_READ = "data.recordComments:read" + DATA_RECORD_COMMENTS_WRITE = "data.recordComments:write" + SCHEMA_BASES_READ = "schema.bases:read" + SCHEMA_BASES_WRITE = "schema.bases:write" + WEBHOOK_MANAGE = "webhook:manage" + BLOCK_MANAGE = "block:manage" + USER_EMAIL_READ = "user.email:read" + + # Enterprise member scopes + ENTERPRISE_GROUPS_READ = "enterprise.groups:read" + WORKSPACES_AND_BASES_READ = "workspacesAndBases:read" + WORKSPACES_AND_BASES_WRITE = "workspacesAndBases:write" + WORKSPACES_AND_BASES_SHARES_MANAGE = "workspacesAndBases.shares:manage" + + # Enterprise admin scopes + ENTERPRISE_SCIM_USERS_AND_GROUPS_MANAGE = "enterprise.scim.usersAndGroups:manage" + ENTERPRISE_AUDIT_LOGS_READ = "enterprise.auditLogs:read" + ENTERPRISE_CHANGE_EVENTS_READ = "enterprise.changeEvents:read" + ENTERPRISE_EXPORTS_MANAGE = "enterprise.exports:manage" + ENTERPRISE_ACCOUNT_READ = "enterprise.account:read" + ENTERPRISE_ACCOUNT_WRITE = "enterprise.account:write" + ENTERPRISE_USER_READ = "enterprise.user:read" + ENTERPRISE_USER_WRITE = "enterprise.user:write" + ENTERPRISE_GROUPS_MANAGE = "enterprise.groups:manage" + WORKSPACES_AND_BASES_MANAGE = "workspacesAndBases:manage" + HYPERDB_RECORDS_READ = "hyperDB.records:read" + HYPERDB_RECORDS_WRITE = "hyperDB.records:write" + + +class AirtableOAuthHandler(BaseOAuthHandler): + """ + OAuth2 handler for Airtable with PKCE support. + """ + + PROVIDER_NAME = ProviderName("airtable") + DEFAULT_SCOPES = [ + v.value + for v in [ + AirtableScope.DATA_RECORDS_READ, + AirtableScope.DATA_RECORDS_WRITE, + AirtableScope.SCHEMA_BASES_READ, + AirtableScope.SCHEMA_BASES_WRITE, + AirtableScope.WEBHOOK_MANAGE, + ] + ] + + def __init__(self, client_id: str, client_secret: Optional[str], redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.scopes = self.DEFAULT_SCOPES + self.auth_base_url = "https://airtable.com/oauth2/v1/authorize" + self.token_url = "https://airtable.com/oauth2/v1/token" + + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: + logger.debug("Generating Airtable OAuth login URL") + # Generate code_challenge if not provided (PKCE is required) + if not scopes: + logger.debug("No scopes provided, using default scopes") + scopes = self.scopes + + logger.debug(f"Using scopes: {scopes}") + logger.debug(f"State: {state}") + logger.debug(f"Code challenge: {code_challenge}") + if not code_challenge: + logger.error("Code challenge is required but none was provided") + raise ValueError("No code challenge provided") + + try: + url = make_oauth_authorize_url( + self.client_id, self.redirect_uri, scopes, state, code_challenge + ) + logger.debug(f"Generated OAuth URL: {url}") + return url + except Exception as e: + logger.error(f"Failed to generate OAuth URL: {str(e)}") + raise + + async def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] + ) -> OAuth2Credentials: + logger.debug("Exchanging authorization code for tokens") + logger.debug(f"Code: {code[:4]}...") # Log first 4 chars only for security + logger.debug(f"Scopes: {scopes}") + if not code_verifier: + logger.error("Code verifier is required but none was provided") + raise ValueError("No code verifier provided") + + try: + response: OAuthTokenResponse = await oauth_exchange_code_for_tokens( + client_id=self.client_id, + code=code, + code_verifier=code_verifier.encode("utf-8"), + redirect_uri=self.redirect_uri, + client_secret=self.client_secret, + ) + logger.info("Successfully exchanged code for tokens") + + credentials = OAuth2Credentials( + access_token=SecretStr(response.access_token), + refresh_token=SecretStr(response.refresh_token), + access_token_expires_at=int(time.time()) + response.expires_in, + refresh_token_expires_at=int(time.time()) + response.refresh_expires_in, + provider=self.PROVIDER_NAME, + scopes=scopes, + ) + logger.debug(f"Access token expires in {response.expires_in} seconds") + logger.debug( + f"Refresh token expires in {response.refresh_expires_in} seconds" + ) + return credentials + + except Exception as e: + logger.error(f"Failed to exchange code for tokens: {str(e)}") + raise + + async def _refresh_tokens( + self, credentials: OAuth2Credentials + ) -> OAuth2Credentials: + logger.debug("Attempting to refresh OAuth tokens") + + if credentials.refresh_token is None: + logger.error("Cannot refresh tokens - no refresh token available") + raise ValueError("No refresh token available") + + try: + response: OAuthTokenResponse = await oauth_refresh_tokens( + client_id=self.client_id, + refresh_token=credentials.refresh_token.get_secret_value(), + client_secret=self.client_secret, + ) + logger.info("Successfully refreshed tokens") + + new_credentials = OAuth2Credentials( + id=credentials.id, + access_token=SecretStr(response.access_token), + refresh_token=SecretStr(response.refresh_token), + access_token_expires_at=int(time.time()) + response.expires_in, + refresh_token_expires_at=int(time.time()) + response.refresh_expires_in, + provider=self.PROVIDER_NAME, + scopes=self.scopes, + ) + logger.debug(f"New access token expires in {response.expires_in} seconds") + logger.debug( + f"New refresh token expires in {response.refresh_expires_in} seconds" + ) + return new_credentials + + except Exception as e: + logger.error(f"Failed to refresh tokens: {str(e)}") + raise + + async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + logger.debug("Token revocation requested") + logger.info( + "Airtable doesn't provide a token revocation endpoint - tokens will expire naturally after 60 minutes" + ) + return False diff --git a/autogpt_platform/backend/backend/blocks/airtable/_webhook.py b/autogpt_platform/backend/backend/blocks/airtable/_webhook.py new file mode 100644 index 000000000000..58e6f95d0c2a --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/airtable/_webhook.py @@ -0,0 +1,154 @@ +""" +Webhook management for Airtable blocks. +""" + +import hashlib +import hmac +import logging +from enum import Enum + +from backend.sdk import ( + BaseWebhooksManager, + Credentials, + ProviderName, + Webhook, + update_webhook, +) + +from ._api import ( + WebhookFilters, + WebhookSpecification, + create_webhook, + delete_webhook, + list_webhook_payloads, +) + +logger = logging.getLogger(__name__) + + +class AirtableWebhookEvent(str, Enum): + TABLE_DATA = "tableData" + TABLE_FIELDS = "tableFields" + TABLE_METADATA = "tableMetadata" + + +class AirtableWebhookManager(BaseWebhooksManager): + """Webhook manager for Airtable API.""" + + PROVIDER_NAME = ProviderName("airtable") + + @classmethod + async def validate_payload( + cls, webhook: Webhook, request, credentials: Credentials | None + ) -> tuple[dict, str]: + """Validate incoming webhook payload and signature.""" + + if not credentials: + raise ValueError("Missing credentials in webhook metadata") + + payload = await request.json() + + # Verify webhook signature using HMAC-SHA256 + if webhook.secret: + mac_secret = webhook.config.get("mac_secret") + if mac_secret: + # Get the raw body for signature verification + body = await request.body() + + # Calculate expected signature + mac_secret_decoded = mac_secret.encode() + hmac_obj = hmac.new(mac_secret_decoded, body, hashlib.sha256) + expected_mac = f"hmac-sha256={hmac_obj.hexdigest()}" + + # Get signature from headers + signature = request.headers.get("X-Airtable-Content-MAC") + + if signature and not hmac.compare_digest(signature, expected_mac): + raise ValueError("Invalid webhook signature") + + # Validate payload structure + required_fields = ["base", "webhook", "timestamp"] + if not all(field in payload for field in required_fields): + raise ValueError("Invalid webhook payload structure") + + if "id" not in payload["base"] or "id" not in payload["webhook"]: + raise ValueError("Missing required IDs in webhook payload") + base_id = payload["base"]["id"] + webhook_id = payload["webhook"]["id"] + + # get payload request parameters + cursor = webhook.config.get("cursor", 1) + + response = await list_webhook_payloads(credentials, base_id, webhook_id, cursor) + + # update webhook config + await update_webhook( + webhook.id, + config={"base_id": base_id, "cursor": response.cursor}, + ) + + event_type = "notification" + return response.model_dump(), event_type + + async def _register_webhook( + self, + credentials: Credentials, + webhook_type: str, + resource: str, + events: list[str], + ingress_url: str, + secret: str, + ) -> tuple[str, dict]: + """Register webhook with Airtable API.""" + + # Parse resource to get base_id and table_id/name + # Resource format: "{base_id}/{table_id_or_name}" + parts = resource.split("/", 1) + if len(parts) != 2: + raise ValueError("Resource must be in format: {base_id}/{table_id_or_name}") + + base_id, table_id_or_name = parts + + # Prepare webhook specification + webhook_specification = WebhookSpecification( + filters=WebhookFilters( + dataTypes=events, + ) + ) + + # Create webhook + webhook_data = await create_webhook( + credentials=credentials, + base_id=base_id, + webhook_specification=webhook_specification, + notification_url=ingress_url, + ) + + webhook_id = webhook_data["id"] + mac_secret = webhook_data.get("macSecretBase64") + + return webhook_id, { + "webhook_id": webhook_id, + "base_id": base_id, + "table_id_or_name": table_id_or_name, + "events": events, + "mac_secret": mac_secret, + "cursor": 1, + "expiration_time": webhook_data.get("expirationTime"), + } + + async def _deregister_webhook( + self, webhook: Webhook, credentials: Credentials + ) -> None: + """Deregister webhook from Airtable API.""" + + base_id = webhook.config.get("base_id") + webhook_id = webhook.config.get("webhook_id") + + if not base_id: + raise ValueError("Missing base_id in webhook metadata") + + if not webhook_id: + raise ValueError("Missing webhook_id in webhook metadata") + + await delete_webhook(credentials, base_id, webhook_id) diff --git a/autogpt_platform/backend/backend/blocks/airtable/bases.py b/autogpt_platform/backend/backend/blocks/airtable/bases.py new file mode 100644 index 000000000000..12910f9e6706 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/airtable/bases.py @@ -0,0 +1,122 @@ +""" +Airtable base operation blocks. +""" + +from typing import Optional + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, +) + +from ._api import create_base, list_bases +from ._config import airtable + + +class AirtableCreateBaseBlock(Block): + """ + Creates a new base in an Airtable workspace. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + workspace_id: str = SchemaField( + description="The workspace ID where the base will be created" + ) + name: str = SchemaField(description="The name of the new base") + tables: list[dict] = SchemaField( + description="At least one table and field must be specified. Array of table objects to create in the base. Each table should have 'name' and 'fields' properties", + default=[ + { + "description": "Default table", + "name": "Default table", + "fields": [ + { + "name": "ID", + "type": "number", + "description": "Auto-incrementing ID field", + "options": {"precision": 0}, + } + ], + } + ], + ) + + class Output(BlockSchema): + base_id: str = SchemaField(description="The ID of the created base") + tables: list[dict] = SchemaField(description="Array of table objects") + table: dict = SchemaField(description="A single table object") + + def __init__(self): + super().__init__( + id="f59b88a8-54ce-4676-a508-fd614b4e8dce", + description="Create a new base in Airtable", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + data = await create_base( + credentials, + input_data.workspace_id, + input_data.name, + input_data.tables, + ) + + yield "base_id", data.get("id", None) + yield "tables", data.get("tables", []) + for table in data.get("tables", []): + yield "table", table + + +class AirtableListBasesBlock(Block): + """ + Lists all bases in an Airtable workspace that the user has access to. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + trigger: str = SchemaField( + description="Trigger the block to run - value is ignored", default="manual" + ) + offset: str = SchemaField( + description="Pagination offset from previous request", default="" + ) + + class Output(BlockSchema): + bases: list[dict] = SchemaField(description="Array of base objects") + offset: Optional[str] = SchemaField( + description="Offset for next page (null if no more bases)", default=None + ) + + def __init__(self): + super().__init__( + id="4bd8d466-ed5d-4e44-8083-97f25a8044e7", + description="List all bases in Airtable", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + data = await list_bases( + credentials, + offset=input_data.offset if input_data.offset else None, + ) + + yield "bases", data.get("bases", []) + yield "offset", data.get("offset", None) diff --git a/autogpt_platform/backend/backend/blocks/airtable/records.py b/autogpt_platform/backend/backend/blocks/airtable/records.py new file mode 100644 index 000000000000..82e5986e847f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/airtable/records.py @@ -0,0 +1,283 @@ +""" +Airtable record operation blocks. +""" + +from typing import Optional + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, +) + +from ._api import ( + create_record, + delete_multiple_records, + get_record, + list_records, + update_multiple_records, +) +from ._config import airtable + + +class AirtableListRecordsBlock(Block): + """ + Lists records from an Airtable table with optional filtering, sorting, and pagination. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="The Airtable base ID") + table_id_or_name: str = SchemaField(description="Table ID or name") + filter_formula: str = SchemaField( + description="Airtable formula to filter records", default="" + ) + view: str = SchemaField(description="View ID or name to use", default="") + sort: list[dict] = SchemaField( + description="Sort configuration (array of {field, direction})", default=[] + ) + max_records: int = SchemaField( + description="Maximum number of records to return", default=100 + ) + page_size: int = SchemaField( + description="Number of records per page (max 100)", default=100 + ) + offset: str = SchemaField( + description="Pagination offset from previous request", default="" + ) + return_fields: list[str] = SchemaField( + description="Specific fields to return (comma-separated)", default=[] + ) + + class Output(BlockSchema): + records: list[dict] = SchemaField(description="Array of record objects") + offset: Optional[str] = SchemaField( + description="Offset for next page (null if no more records)", default=None + ) + + def __init__(self): + super().__init__( + id="588a9fde-5733-4da7-b03c-35f5671e960f", + description="List records from an Airtable table", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + data = await list_records( + credentials, + input_data.base_id, + input_data.table_id_or_name, + filter_by_formula=( + input_data.filter_formula if input_data.filter_formula else None + ), + view=input_data.view if input_data.view else None, + sort=input_data.sort if input_data.sort else None, + max_records=input_data.max_records if input_data.max_records else None, + page_size=min(input_data.page_size, 100) if input_data.page_size else None, + offset=input_data.offset if input_data.offset else None, + fields=input_data.return_fields if input_data.return_fields else None, + ) + + yield "records", data.get("records", []) + yield "offset", data.get("offset", None) + + +class AirtableGetRecordBlock(Block): + """ + Retrieves a single record from an Airtable table by its ID. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="The Airtable base ID") + table_id_or_name: str = SchemaField(description="Table ID or name") + record_id: str = SchemaField(description="The record ID to retrieve") + + class Output(BlockSchema): + id: str = SchemaField(description="The record ID") + fields: dict = SchemaField(description="The record fields") + created_time: str = SchemaField(description="The record created time") + + def __init__(self): + super().__init__( + id="c29c5cbf-0aff-40f9-bbb5-f26061792d2b", + description="Get a single record from Airtable", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + record = await get_record( + credentials, + input_data.base_id, + input_data.table_id_or_name, + input_data.record_id, + ) + + yield "id", record.get("id", None) + yield "fields", record.get("fields", None) + yield "created_time", record.get("createdTime", None) + + +class AirtableCreateRecordsBlock(Block): + """ + Creates one or more records in an Airtable table. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="The Airtable base ID") + table_id_or_name: str = SchemaField(description="Table ID or name") + records: list[dict] = SchemaField( + description="Array of records to create (each with 'fields' object)" + ) + typecast: bool = SchemaField( + description="Automatically convert string values to appropriate types", + default=False, + ) + return_fields_by_field_id: bool | None = SchemaField( + description="Return fields by field ID", + default=None, + ) + + class Output(BlockSchema): + records: list[dict] = SchemaField(description="Array of created record objects") + details: dict = SchemaField(description="Details of the created records") + + def __init__(self): + super().__init__( + id="42527e98-47b6-44ce-ac0e-86b4883721d3", + description="Create records in an Airtable table", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + # The create_record API expects records in a specific format + data = await create_record( + credentials, + input_data.base_id, + input_data.table_id_or_name, + records=[{"fields": record} for record in input_data.records], + typecast=input_data.typecast if input_data.typecast else None, + return_fields_by_field_id=input_data.return_fields_by_field_id, + ) + + yield "records", data.get("records", []) + details = data.get("details", None) + if details: + yield "details", details + + +class AirtableUpdateRecordsBlock(Block): + """ + Updates one or more existing records in an Airtable table. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="The Airtable base ID") + table_id_or_name: str = SchemaField( + description="Table ID or name - It's better to use the table ID instead of the name" + ) + records: list[dict] = SchemaField( + description="Array of records to update (each with 'id' and 'fields')" + ) + typecast: bool | None = SchemaField( + description="Automatically convert string values to appropriate types", + default=None, + ) + + class Output(BlockSchema): + records: list[dict] = SchemaField(description="Array of updated record objects") + + def __init__(self): + super().__init__( + id="6e7d2590-ac2b-4b5d-b08c-fc039cd77e1f", + description="Update records in an Airtable table", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + # The update_multiple_records API expects records with id and fields + data = await update_multiple_records( + credentials, + input_data.base_id, + input_data.table_id_or_name, + records=input_data.records, + typecast=input_data.typecast if input_data.typecast else None, + return_fields_by_field_id=False, # Use field names, not IDs + ) + + yield "records", data.get("records", []) + + +class AirtableDeleteRecordsBlock(Block): + """ + Deletes one or more records from an Airtable table. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="The Airtable base ID") + table_id_or_name: str = SchemaField( + description="Table ID or name - It's better to use the table ID instead of the name" + ) + record_ids: list[str] = SchemaField( + description="Array of upto 10 record IDs to delete" + ) + + class Output(BlockSchema): + records: list[dict] = SchemaField(description="Array of deletion results") + + def __init__(self): + super().__init__( + id="93e22b8b-3642-4477-aefb-1c0929a4a3a6", + description="Delete records from an Airtable table", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + if len(input_data.record_ids) > 10: + yield "error", "Only upto 10 record IDs can be deleted at a time" + else: + data = await delete_multiple_records( + credentials, + input_data.base_id, + input_data.table_id_or_name, + input_data.record_ids, + ) + + yield "records", data.get("records", []) diff --git a/autogpt_platform/backend/backend/blocks/airtable/schema.py b/autogpt_platform/backend/backend/blocks/airtable/schema.py new file mode 100644 index 000000000000..5d2006a2ffb0 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/airtable/schema.py @@ -0,0 +1,252 @@ +""" +Airtable schema and table management blocks. +""" + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + Requests, + SchemaField, +) + +from ._api import TableFieldType, create_field, create_table, update_field, update_table +from ._config import airtable + + +class AirtableListSchemaBlock(Block): + """ + Retrieves the complete schema of an Airtable base, including all tables, + fields, and views. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="The Airtable base ID") + + class Output(BlockSchema): + base_schema: dict = SchemaField( + description="Complete base schema with tables, fields, and views" + ) + tables: list[dict] = SchemaField(description="Array of table objects") + + def __init__(self): + super().__init__( + id="64291d3c-99b5-47b7-a976-6d94293cdb2d", + description="Get the complete schema of an Airtable base", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + api_key = credentials.api_key.get_secret_value() + + # Get base schema + response = await Requests().get( + f"https://api.airtable.com/v0/meta/bases/{input_data.base_id}/tables", + headers={"Authorization": f"Bearer {api_key}"}, + ) + + data = response.json() + + yield "base_schema", data + yield "tables", data.get("tables", []) + + +class AirtableCreateTableBlock(Block): + """ + Creates a new table in an Airtable base with specified fields and views. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="The Airtable base ID") + table_name: str = SchemaField(description="The name of the table to create") + table_fields: list[dict] = SchemaField( + description="Table fields with name, type, and options", + default=[{"name": "Name", "type": "singleLineText"}], + ) + + class Output(BlockSchema): + table: dict = SchemaField(description="Created table object") + table_id: str = SchemaField(description="ID of the created table") + + def __init__(self): + super().__init__( + id="fcc20ced-d817-42ea-9b40-c35e7bf34b4f", + description="Create a new table in an Airtable base", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + table_data = await create_table( + credentials, + input_data.base_id, + input_data.table_name, + input_data.table_fields, + ) + + yield "table", table_data + yield "table_id", table_data.get("id", "") + + +class AirtableUpdateTableBlock(Block): + """ + Updates an existing table's properties such as name or description. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="The Airtable base ID") + table_id: str = SchemaField(description="The table ID to update") + table_name: str | None = SchemaField( + description="The name of the table to update", default=None + ) + table_description: str | None = SchemaField( + description="The description of the table to update", default=None + ) + date_dependency: dict | None = SchemaField( + description="The date dependency of the table to update", default=None + ) + + class Output(BlockSchema): + table: dict = SchemaField(description="Updated table object") + + def __init__(self): + super().__init__( + id="34077c5f-f962-49f2-9ec6-97c67077013a", + description="Update table properties", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + table_data = await update_table( + credentials, + input_data.base_id, + input_data.table_id, + input_data.table_name, + input_data.table_description, + input_data.date_dependency, + ) + + yield "table", table_data + + +class AirtableCreateFieldBlock(Block): + """ + Adds a new field (column) to an existing Airtable table. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="The Airtable base ID") + table_id: str = SchemaField(description="The table ID to add field to") + field_type: TableFieldType = SchemaField( + description="The type of the field to create", + default=TableFieldType.SINGLE_LINE_TEXT, + advanced=False, + ) + name: str = SchemaField(description="The name of the field to create") + description: str | None = SchemaField( + description="The description of the field to create", default=None + ) + options: dict[str, str] | None = SchemaField( + description="The options of the field to create", default=None + ) + + class Output(BlockSchema): + field: dict = SchemaField(description="Created field object") + field_id: str = SchemaField(description="ID of the created field") + + def __init__(self): + super().__init__( + id="6c98a32f-dbf9-45d8-a2a8-5e97e8326351", + description="Add a new field to an Airtable table", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + field_data = await create_field( + credentials, + input_data.base_id, + input_data.table_id, + input_data.field_type, + input_data.name, + ) + + yield "field", field_data + yield "field_id", field_data.get("id", "") + + +class AirtableUpdateFieldBlock(Block): + """ + Updates an existing field's properties in an Airtable table. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="The Airtable base ID") + table_id: str = SchemaField(description="The table ID containing the field") + field_id: str = SchemaField(description="The field ID to update") + name: str | None = SchemaField( + description="The name of the field to update", default=None, advanced=False + ) + description: str | None = SchemaField( + description="The description of the field to update", + default=None, + advanced=False, + ) + + class Output(BlockSchema): + field: dict = SchemaField(description="Updated field object") + + def __init__(self): + super().__init__( + id="f46ac716-3b18-4da1-92e4-34ca9a464d48", + description="Update field properties in an Airtable table", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + field_data = await update_field( + credentials, + input_data.base_id, + input_data.table_id, + input_data.field_id, + input_data.name, + input_data.description, + ) + + yield "field", field_data diff --git a/autogpt_platform/backend/backend/blocks/airtable/triggers.py b/autogpt_platform/backend/backend/blocks/airtable/triggers.py new file mode 100644 index 000000000000..2cfc8178e304 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/airtable/triggers.py @@ -0,0 +1,113 @@ +from backend.sdk import ( + BaseModel, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + BlockWebhookConfig, + CredentialsMetaInput, + ProviderName, + SchemaField, +) + +from ._api import WebhookPayload +from ._config import airtable + + +class AirtableEventSelector(BaseModel): + """ + Selects the Airtable webhook event to trigger on. + """ + + tableData: bool = True + tableFields: bool = True + tableMetadata: bool = True + + +class AirtableWebhookTriggerBlock(Block): + """ + Starts a flow whenever Airtable emits a webhook event. + + Thin wrapper just forwards the payloads one at a time to the next block. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = airtable.credentials_field( + description="Airtable API credentials" + ) + base_id: str = SchemaField(description="Airtable base ID") + table_id_or_name: str = SchemaField(description="Airtable table ID or name") + payload: dict = SchemaField(hidden=True, default_factory=dict) + events: AirtableEventSelector = SchemaField( + description="Airtable webhook event filter" + ) + + class Output(BlockSchema): + payload: WebhookPayload = SchemaField(description="Airtable webhook payload") + + def __init__(self): + example_payload = { + "payloads": [ + { + "timestamp": "2022-02-01T21:25:05.663Z", + "baseTransactionNumber": 4, + "actionMetadata": { + "source": "client", + "sourceMetadata": { + "user": { + "id": "usr00000000000000", + "email": "foo@bar.com", + "permissionLevel": "create", + } + }, + }, + "payloadFormat": "v0", + } + ], + "cursor": 5, + "mightHaveMore": False, + } + + super().__init__( + # NOTE: This is disabled whilst the webhook system is finalised. + disabled=False, + id="d0180ce6-ccb9-48c7-8256-b39e93e62801", + description="Starts a flow whenever Airtable emits a webhook event", + categories={BlockCategory.INPUT, BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + block_type=BlockType.WEBHOOK, + webhook_config=BlockWebhookConfig( + provider=ProviderName("airtable"), + webhook_type="not-used", + event_filter_input="events", + event_format="{event}", + resource_format="{base_id}/{table_id_or_name}", + ), + test_input={ + "credentials": airtable.get_test_credentials().model_dump(), + "base_id": "app1234567890", + "table_id_or_name": "table1234567890", + "events": AirtableEventSelector( + tableData=True, + tableFields=True, + tableMetadata=False, + ).model_dump(), + "payload": example_payload, + }, + test_credentials=airtable.get_test_credentials(), + test_output=[ + ( + "payload", + WebhookPayload.model_validate(example_payload["payloads"][0]), + ), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + if len(input_data.payload["payloads"]) > 0: + for item in input_data.payload["payloads"]: + yield "payload", WebhookPayload.model_validate(item) + else: + yield "error", "No valid payloads found in webhook payload" diff --git a/autogpt_platform/backend/backend/blocks/apollo/_api.py b/autogpt_platform/backend/backend/blocks/apollo/_api.py new file mode 100644 index 000000000000..6a72ad313d14 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/apollo/_api.py @@ -0,0 +1,131 @@ +import logging +from typing import List + +from backend.blocks.apollo._auth import ApolloCredentials +from backend.blocks.apollo.models import ( + Contact, + EnrichPersonRequest, + Organization, + SearchOrganizationsRequest, + SearchOrganizationsResponse, + SearchPeopleRequest, + SearchPeopleResponse, +) +from backend.util.request import Requests + +logger = logging.getLogger(name=__name__) + + +class ApolloClient: + """Client for the Apollo API""" + + API_URL = "https://api.apollo.io/api/v1" + + def __init__(self, credentials: ApolloCredentials): + self.credentials = credentials + self.requests = Requests() + + def _get_headers(self) -> dict[str, str]: + return {"x-api-key": self.credentials.api_key.get_secret_value()} + + async def search_people(self, query: SearchPeopleRequest) -> List[Contact]: + """Search for people in Apollo""" + response = await self.requests.post( + f"{self.API_URL}/mixed_people/search", + headers=self._get_headers(), + json=query.model_dump(exclude={"max_results"}), + ) + data = response.json() + parsed_response = SearchPeopleResponse(**data) + if parsed_response.pagination.total_entries == 0: + return [] + + people = parsed_response.people + + # handle pagination + if ( + query.max_results is not None + and query.max_results < parsed_response.pagination.total_entries + and len(people) < query.max_results + ): + while ( + len(people) < query.max_results + and query.page < parsed_response.pagination.total_pages + and len(parsed_response.people) > 0 + ): + query.page += 1 + response = await self.requests.post( + f"{self.API_URL}/mixed_people/search", + headers=self._get_headers(), + json=query.model_dump(exclude={"max_results"}), + ) + data = response.json() + parsed_response = SearchPeopleResponse(**data) + people.extend(parsed_response.people[: query.max_results - len(people)]) + + logger.info(f"Found {len(people)} people") + return people[: query.max_results] if query.max_results else people + + async def search_organizations( + self, query: SearchOrganizationsRequest + ) -> List[Organization]: + """Search for organizations in Apollo""" + response = await self.requests.post( + f"{self.API_URL}/mixed_companies/search", + headers=self._get_headers(), + json=query.model_dump(exclude={"max_results"}), + ) + data = response.json() + parsed_response = SearchOrganizationsResponse(**data) + if parsed_response.pagination.total_entries == 0: + return [] + + organizations = parsed_response.organizations + + # handle pagination + if ( + query.max_results is not None + and query.max_results < parsed_response.pagination.total_entries + and len(organizations) < query.max_results + ): + while ( + len(organizations) < query.max_results + and query.page < parsed_response.pagination.total_pages + and len(parsed_response.organizations) > 0 + ): + query.page += 1 + response = await self.requests.post( + f"{self.API_URL}/mixed_companies/search", + headers=self._get_headers(), + json=query.model_dump(exclude={"max_results"}), + ) + data = response.json() + parsed_response = SearchOrganizationsResponse(**data) + organizations.extend( + parsed_response.organizations[ + : query.max_results - len(organizations) + ] + ) + + logger.info(f"Found {len(organizations)} organizations") + return ( + organizations[: query.max_results] if query.max_results else organizations + ) + + async def enrich_person(self, query: EnrichPersonRequest) -> Contact: + """Enrich a person's data including email & phone reveal""" + response = await self.requests.post( + f"{self.API_URL}/people/match", + headers=self._get_headers(), + json=query.model_dump(), + params={ + "reveal_personal_emails": "true", + }, + ) + data = response.json() + if "person" not in data: + raise ValueError(f"Person not found or enrichment failed: {data}") + + contact = Contact(**data["person"]) + contact.email = contact.email or "-" + return contact diff --git a/autogpt_platform/backend/backend/blocks/apollo/_auth.py b/autogpt_platform/backend/backend/blocks/apollo/_auth.py new file mode 100644 index 000000000000..c813c72d9968 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/apollo/_auth.py @@ -0,0 +1,35 @@ +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import APIKeyCredentials, CredentialsField, CredentialsMetaInput +from backend.integrations.providers import ProviderName + +ApolloCredentials = APIKeyCredentials +ApolloCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.APOLLO], + Literal["api_key"], +] + +TEST_CREDENTIALS = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="apollo", + api_key=SecretStr("mock-apollo-api-key"), + title="Mock Apollo API key", + expires_at=None, +) +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} + + +def ApolloCredentialsField() -> ApolloCredentialsInput: + """ + Creates a Apollo credentials input on a block. + """ + return CredentialsField( + description="The Apollo integration can be used with an API Key.", + ) diff --git a/autogpt_platform/backend/backend/blocks/apollo/models.py b/autogpt_platform/backend/backend/blocks/apollo/models.py new file mode 100644 index 000000000000..4dde8f29b11d --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/apollo/models.py @@ -0,0 +1,607 @@ +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel as OriginalBaseModel +from pydantic import ConfigDict + +from backend.data.model import SchemaField + + +class BaseModel(OriginalBaseModel): + def model_dump(self, *args, exclude: set[str] | None = None, **kwargs): + if exclude is None: + exclude = set("credentials") + else: + exclude.add("credentials") + + kwargs.setdefault("exclude_none", True) + kwargs.setdefault("exclude_unset", True) + kwargs.setdefault("exclude_defaults", True) + return super().model_dump(*args, exclude=exclude, **kwargs) + + +class PrimaryPhone(BaseModel): + """A primary phone in Apollo""" + + number: Optional[str] = "" + source: Optional[str] = "" + sanitized_number: Optional[str] = "" + + +class SenorityLevels(str, Enum): + """Seniority levels in Apollo""" + + OWNER = "owner" + FOUNDER = "founder" + C_SUITE = "c_suite" + PARTNER = "partner" + VP = "vp" + HEAD = "head" + DIRECTOR = "director" + MANAGER = "manager" + SENIOR = "senior" + ENTRY = "entry" + INTERN = "intern" + + +class ContactEmailStatuses(str, Enum): + """Contact email statuses in Apollo""" + + VERIFIED = "verified" + UNVERIFIED = "unverified" + LIKELY_TO_ENGAGE = "likely_to_engage" + UNAVAILABLE = "unavailable" + + +class RuleConfigStatus(BaseModel): + """A rule config status in Apollo""" + + _id: Optional[str] = "" + created_at: Optional[str] = "" + rule_action_config_id: Optional[str] = "" + rule_config_id: Optional[str] = "" + status_cd: Optional[str] = "" + updated_at: Optional[str] = "" + id: Optional[str] = "" + key: Optional[str] = "" + + +class ContactCampaignStatus(BaseModel): + """A contact campaign status in Apollo""" + + id: Optional[str] = "" + emailer_campaign_id: Optional[str] = "" + send_email_from_user_id: Optional[str] = "" + inactive_reason: Optional[str] = "" + status: Optional[str] = "" + added_at: Optional[str] = "" + added_by_user_id: Optional[str] = "" + finished_at: Optional[str] = "" + paused_at: Optional[str] = "" + auto_unpause_at: Optional[str] = "" + send_email_from_email_address: Optional[str] = "" + send_email_from_email_account_id: Optional[str] = "" + manually_set_unpause: Optional[str] = "" + failure_reason: Optional[str] = "" + current_step_id: Optional[str] = "" + in_response_to_emailer_message_id: Optional[str] = "" + cc_emails: Optional[str] = "" + bcc_emails: Optional[str] = "" + to_emails: Optional[str] = "" + + +class Account(BaseModel): + """An account in Apollo""" + + id: Optional[str] = "" + name: Optional[str] = "" + website_url: Optional[str] = "" + blog_url: Optional[str] = "" + angellist_url: Optional[str] = "" + linkedin_url: Optional[str] = "" + twitter_url: Optional[str] = "" + facebook_url: Optional[str] = "" + primary_phone: Optional[PrimaryPhone] = PrimaryPhone() + languages: Optional[list[str]] = [] + alexa_ranking: Optional[int] = 0 + phone: Optional[str] = "" + linkedin_uid: Optional[str] = "" + founded_year: Optional[int] = 0 + publicly_traded_symbol: Optional[str] = "" + publicly_traded_exchange: Optional[str] = "" + logo_url: Optional[str] = "" + chrunchbase_url: Optional[str] = "" + primary_domain: Optional[str] = "" + domain: Optional[str] = "" + team_id: Optional[str] = "" + organization_id: Optional[str] = "" + account_stage_id: Optional[str] = "" + source: Optional[str] = "" + original_source: Optional[str] = "" + creator_id: Optional[str] = "" + owner_id: Optional[str] = "" + created_at: Optional[str] = "" + phone_status: Optional[str] = "" + hubspot_id: Optional[str] = "" + salesforce_id: Optional[str] = "" + crm_owner_id: Optional[str] = "" + parent_account_id: Optional[str] = "" + sanitized_phone: Optional[str] = "" + # no listed type on the API docs + account_playbook_statues: Optional[list[Any]] = [] + account_rule_config_statuses: Optional[list[RuleConfigStatus]] = [] + existence_level: Optional[str] = "" + label_ids: Optional[list[str]] = [] + typed_custom_fields: Optional[Any] = {} + custom_field_errors: Optional[Any] = {} + modality: Optional[str] = "" + source_display_name: Optional[str] = "" + salesforce_record_id: Optional[str] = "" + crm_record_url: Optional[str] = "" + + +class ContactEmail(BaseModel): + """A contact email in Apollo""" + + email: Optional[str] = "" + email_md5: Optional[str] = "" + email_sha256: Optional[str] = "" + email_status: Optional[str] = "" + email_source: Optional[str] = "" + extrapolated_email_confidence: Optional[str] = "" + position: Optional[int] = 0 + email_from_customer: Optional[str] = "" + free_domain: Optional[bool] = True + + +class EmploymentHistory(BaseModel): + """An employment history in Apollo""" + + model_config = ConfigDict( + extra="allow", + arbitrary_types_allowed=True, + from_attributes=True, + populate_by_name=True, + ) + + _id: Optional[str] = "" + created_at: Optional[str] = "" + current: Optional[bool] = False + degree: Optional[str] = "" + description: Optional[str] = "" + emails: Optional[str] = "" + end_date: Optional[str] = "" + grade_level: Optional[str] = "" + kind: Optional[str] = "" + major: Optional[str] = "" + organization_id: Optional[str] = "" + organization_name: Optional[str] = "" + raw_address: Optional[str] = "" + start_date: Optional[str] = "" + title: Optional[str] = "" + updated_at: Optional[str] = "" + id: Optional[str] = "" + key: Optional[str] = "" + + +class Breadcrumb(BaseModel): + """A breadcrumb in Apollo""" + + label: Optional[str] = "" + signal_field_name: Optional[str] = "" + value: str | list | None = "" + display_name: Optional[str] = "" + + +class TypedCustomField(BaseModel): + """A typed custom field in Apollo""" + + id: Optional[str] = "" + value: Optional[str] = "" + + +class Pagination(BaseModel): + """Pagination in Apollo""" + + model_config = ConfigDict( + extra="allow", + arbitrary_types_allowed=True, + from_attributes=True, + populate_by_name=True, + ) + + page: int = 0 + per_page: int = 0 + total_entries: int = 0 + total_pages: int = 0 + + +class DialerFlags(BaseModel): + """A dialer flags in Apollo""" + + country_name: Optional[str] = "" + country_enabled: Optional[bool] = True + high_risk_calling_enabled: Optional[bool] = True + potential_high_risk_number: Optional[bool] = True + + +class PhoneNumber(BaseModel): + """A phone number in Apollo""" + + raw_number: Optional[str] = "" + sanitized_number: Optional[str] = "" + type: Optional[str] = "" + position: Optional[int] = 0 + status: Optional[str] = "" + dnc_status: Optional[str] = "" + dnc_other_info: Optional[str] = "" + dailer_flags: Optional[DialerFlags] = DialerFlags( + country_name="", + country_enabled=True, + high_risk_calling_enabled=True, + potential_high_risk_number=True, + ) + + +class Organization(BaseModel): + """An organization in Apollo""" + + model_config = ConfigDict( + extra="allow", + arbitrary_types_allowed=True, + from_attributes=True, + populate_by_name=True, + ) + + id: Optional[str] = "" + name: Optional[str] = "" + website_url: Optional[str] = "" + blog_url: Optional[str] = "" + angellist_url: Optional[str] = "" + linkedin_url: Optional[str] = "" + twitter_url: Optional[str] = "" + facebook_url: Optional[str] = "" + primary_phone: Optional[PrimaryPhone] = PrimaryPhone() + languages: Optional[list[str]] = [] + alexa_ranking: Optional[int] = 0 + phone: Optional[str] = "" + linkedin_uid: Optional[str] = "" + founded_year: Optional[int] = 0 + publicly_traded_symbol: Optional[str] = "" + publicly_traded_exchange: Optional[str] = "" + logo_url: Optional[str] = "" + chrunchbase_url: Optional[str] = "" + primary_domain: Optional[str] = "" + sanitized_phone: Optional[str] = "" + owned_by_organization_id: Optional[str] = "" + intent_strength: Optional[str] = "" + show_intent: Optional[bool] = True + has_intent_signal_account: Optional[bool] = True + intent_signal_account: Optional[str] = "" + + +class Contact(BaseModel): + """A contact in Apollo""" + + model_config = ConfigDict( + extra="allow", + arbitrary_types_allowed=True, + from_attributes=True, + populate_by_name=True, + ) + + contact_roles: Optional[list[Any]] = [] + id: Optional[str] = "" + first_name: Optional[str] = "" + last_name: Optional[str] = "" + name: Optional[str] = "" + linkedin_url: Optional[str] = "" + title: Optional[str] = "" + contact_stage_id: Optional[str] = "" + owner_id: Optional[str] = "" + creator_id: Optional[str] = "" + person_id: Optional[str] = "" + email_needs_tickling: Optional[bool] = True + organization_name: Optional[str] = "" + source: Optional[str] = "" + original_source: Optional[str] = "" + organization_id: Optional[str] = "" + headline: Optional[str] = "" + photo_url: Optional[str] = "" + present_raw_address: Optional[str] = "" + linkededin_uid: Optional[str] = "" + extrapolated_email_confidence: Optional[float] = 0.0 + salesforce_id: Optional[str] = "" + salesforce_lead_id: Optional[str] = "" + salesforce_contact_id: Optional[str] = "" + saleforce_account_id: Optional[str] = "" + crm_owner_id: Optional[str] = "" + created_at: Optional[str] = "" + emailer_campaign_ids: Optional[list[str]] = [] + direct_dial_status: Optional[str] = "" + direct_dial_enrichment_failed_at: Optional[str] = "" + email_status: Optional[str] = "" + email_source: Optional[str] = "" + account_id: Optional[str] = "" + last_activity_date: Optional[str] = "" + hubspot_vid: Optional[str] = "" + hubspot_company_id: Optional[str] = "" + crm_id: Optional[str] = "" + sanitized_phone: Optional[str] = "" + merged_crm_ids: Optional[str] = "" + updated_at: Optional[str] = "" + queued_for_crm_push: Optional[bool] = True + suggested_from_rule_engine_config_id: Optional[str] = "" + email_unsubscribed: Optional[str] = "" + label_ids: Optional[list[Any]] = [] + has_pending_email_arcgate_request: Optional[bool] = True + has_email_arcgate_request: Optional[bool] = True + existence_level: Optional[str] = "" + email: Optional[str] = "" + email_from_customer: Optional[str] = "" + typed_custom_fields: Optional[list[TypedCustomField]] = [] + custom_field_errors: Optional[Any] = {} + salesforce_record_id: Optional[str] = "" + crm_record_url: Optional[str] = "" + email_status_unavailable_reason: Optional[str] = "" + email_true_status: Optional[str] = "" + updated_email_true_status: Optional[bool] = True + contact_rule_config_statuses: Optional[list[RuleConfigStatus]] = [] + source_display_name: Optional[str] = "" + twitter_url: Optional[str] = "" + contact_campaign_statuses: Optional[list[ContactCampaignStatus]] = [] + state: Optional[str] = "" + city: Optional[str] = "" + country: Optional[str] = "" + account: Optional[Account] = Account() + contact_emails: Optional[list[ContactEmail]] = [] + organization: Optional[Organization] = Organization() + employment_history: Optional[list[EmploymentHistory]] = [] + time_zone: Optional[str] = "" + intent_strength: Optional[str] = "" + show_intent: Optional[bool] = True + phone_numbers: Optional[list[PhoneNumber]] = [] + account_phone_note: Optional[str] = "" + free_domain: Optional[bool] = True + is_likely_to_engage: Optional[bool] = True + email_domain_catchall: Optional[bool] = True + contact_job_change_event: Optional[str] = "" + + +class SearchOrganizationsRequest(BaseModel): + """Request for Apollo's search organizations API""" + + organization_num_employees_range: Optional[list[int]] = SchemaField( + description="""The number range of employees working for the company. This enables you to find companies based on headcount. You can add multiple ranges to expand your search results. + +Each range you add needs to be a string, with the upper and lower numbers of the range separated only by a comma.""", + default=[0, 1000000], + ) + + organization_locations: Optional[list[str]] = SchemaField( + description="""The location of the company headquarters. You can search across cities, US states, and countries. + +If a company has several office locations, results are still based on the headquarters location. For example, if you search chicago but a company's HQ location is in boston, any Boston-based companies will not appearch in your search results, even if they match other parameters. + +To exclude companies based on location, use the organization_not_locations parameter. +""", + default_factory=list, + ) + organizations_not_locations: Optional[list[str]] = SchemaField( + description="""Exclude companies from search results based on the location of the company headquarters. You can use cities, US states, and countries as locations to exclude. + +This parameter is useful for ensuring you do not prospect in an undesirable territory. For example, if you use ireland as a value, no Ireland-based companies will appear in your search results. +""", + default_factory=list, + ) + q_organization_keyword_tags: Optional[list[str]] = SchemaField( + description="""Filter search results based on keywords associated with companies. For example, you can enter mining as a value to return only companies that have an association with the mining industry.""", + default_factory=list, + ) + q_organization_name: Optional[str] = SchemaField( + description="""Filter search results to include a specific company name. + +If the value you enter for this parameter does not match with a company's name, the company will not appear in search results, even if it matches other parameters. Partial matches are accepted. For example, if you filter by the value marketing, a company called NY Marketing Unlimited would still be eligible as a search result, but NY Market Analysis would not be eligible.""", + default="", + ) + organization_ids: Optional[list[str]] = SchemaField( + description="""The Apollo IDs for the companies you want to include in your search results. Each company in the Apollo database is assigned a unique ID. + +To find IDs, identify the values for organization_id when you call this endpoint.""", + default_factory=list, + ) + max_results: Optional[int] = SchemaField( + description="""The maximum number of results to return. If you don't specify this parameter, the default is 100.""", + default=100, + ge=1, + le=50000, + advanced=True, + ) + + page: int = SchemaField( + description="""The page number of the Apollo data that you want to retrieve. + +Use this parameter in combination with the per_page parameter to make search results for navigable and improve the performance of the endpoint.""", + default=1, + ) + per_page: int = SchemaField( + description="""The number of search results that should be returned for each page. Limited the number of results per page improves the endpoint's performance. + +Use the page parameter to search the different pages of data.""", + default=100, + ) + + +class SearchOrganizationsResponse(BaseModel): + """Response from Apollo's search organizations API""" + + breadcrumbs: Optional[list[Breadcrumb]] = [] + partial_results_only: Optional[bool] = True + has_join: Optional[bool] = True + disable_eu_prospecting: Optional[bool] = True + partial_results_limit: Optional[int] = 0 + pagination: Pagination = Pagination( + page=0, per_page=0, total_entries=0, total_pages=0 + ) + # no listed type on the API docs + accounts: list[Any] = [] + organizations: list[Organization] = [] + models_ids: list[str] = [] + num_fetch_result: Optional[str] = "" + derived_params: Optional[str] = "" + + +class SearchPeopleRequest(BaseModel): + """Request for Apollo's search people API""" + + person_titles: Optional[list[str]] = SchemaField( + description="""Job titles held by the people you want to find. For a person to be included in search results, they only need to match 1 of the job titles you add. Adding more job titles expands your search results. + +Results also include job titles with the same terms, even if they are not exact matches. For example, searching for marketing manager might return people with the job title content marketing manager. + +Use this parameter in combination with the person_seniorities[] parameter to find people based on specific job functions and seniority levels. +""", + default_factory=list, + placeholder="marketing manager", + ) + person_locations: Optional[list[str]] = SchemaField( + description="""The location where people live. You can search across cities, US states, and countries. + +To find people based on the headquarters locations of their current employer, use the organization_locations parameter.""", + default_factory=list, + ) + person_seniorities: Optional[list[SenorityLevels]] = SchemaField( + description="""The job seniority that people hold within their current employer. This enables you to find people that currently hold positions at certain reporting levels, such as Director level or senior IC level. + +For a person to be included in search results, they only need to match 1 of the seniorities you add. Adding more seniorities expands your search results. + +Searches only return results based on their current job title, so searching for Director-level employees only returns people that currently hold a Director-level title. If someone was previously a Director, but is currently a VP, they would not be included in your search results. + +Use this parameter in combination with the person_titles[] parameter to find people based on specific job functions and seniority levels.""", + default_factory=list, + ) + organization_locations: Optional[list[str]] = SchemaField( + description="""The location of the company headquarters for a person's current employer. You can search across cities, US states, and countries. + +If a company has several office locations, results are still based on the headquarters location. For example, if you search chicago but a company's HQ location is in boston, people that work for the Boston-based company will not appear in your results, even if they match other parameters. + +To find people based on their personal location, use the person_locations parameter.""", + default_factory=list, + ) + q_organization_domains: Optional[list[str]] = SchemaField( + description="""The domain name for the person's employer. This can be the current employer or a previous employer. Do not include www., the @ symbol, or similar. + +You can add multiple domains to search across companies. + + Examples: apollo.io and microsoft.com""", + default_factory=list, + ) + contact_email_statuses: Optional[list[ContactEmailStatuses]] = SchemaField( + description="""The email statuses for the people you want to find. You can add multiple statuses to expand your search.""", + default_factory=list, + ) + organization_ids: Optional[list[str]] = SchemaField( + description="""The Apollo IDs for the companies (employers) you want to include in your search results. Each company in the Apollo database is assigned a unique ID. + +To find IDs, call the Organization Search endpoint and identify the values for organization_id.""", + default_factory=list, + ) + organization_num_employees_range: Optional[list[int]] = SchemaField( + description="""The number range of employees working for the company. This enables you to find companies based on headcount. You can add multiple ranges to expand your search results. + +Each range you add needs to be a string, with the upper and lower numbers of the range separated only by a comma.""", + default_factory=list, + ) + q_keywords: Optional[str] = SchemaField( + description="""A string of words over which we want to filter the results""", + default="", + ) + page: int = SchemaField( + description="""The page number of the Apollo data that you want to retrieve. + +Use this parameter in combination with the per_page parameter to make search results for navigable and improve the performance of the endpoint.""", + default=1, + ) + per_page: int = SchemaField( + description="""The number of search results that should be returned for each page. Limited the number of results per page improves the endpoint's performance. + +Use the page parameter to search the different pages of data.""", + default=100, + ) + max_results: Optional[int] = SchemaField( + description="""The maximum number of results to return. If you don't specify this parameter, the default is 100.""", + default=100, + ge=1, + le=50000, + advanced=True, + ) + + +class SearchPeopleResponse(BaseModel): + """Response from Apollo's search people API""" + + model_config = ConfigDict( + extra="allow", + arbitrary_types_allowed=True, + from_attributes=True, + populate_by_name=True, + ) + + breadcrumbs: Optional[list[Breadcrumb]] = [] + partial_results_only: Optional[bool] = True + has_join: Optional[bool] = True + disable_eu_prospecting: Optional[bool] = True + partial_results_limit: Optional[int] = 0 + pagination: Pagination = Pagination( + page=0, per_page=0, total_entries=0, total_pages=0 + ) + contacts: list[Contact] = [] + people: list[Contact] = [] + model_ids: list[str] = [] + num_fetch_result: Optional[str] = "" + derived_params: Optional[str] = "" + + +class EnrichPersonRequest(BaseModel): + """Request for Apollo's person enrichment API""" + + person_id: Optional[str] = SchemaField( + description="Apollo person ID to enrich (most accurate method)", + default="", + ) + first_name: Optional[str] = SchemaField( + description="First name of the person to enrich", + default="", + ) + last_name: Optional[str] = SchemaField( + description="Last name of the person to enrich", + default="", + ) + name: Optional[str] = SchemaField( + description="Full name of the person to enrich", + default="", + ) + email: Optional[str] = SchemaField( + description="Email address of the person to enrich", + default="", + ) + domain: Optional[str] = SchemaField( + description="Company domain of the person to enrich", + default="", + ) + company: Optional[str] = SchemaField( + description="Company name of the person to enrich", + default="", + ) + linkedin_url: Optional[str] = SchemaField( + description="LinkedIn URL of the person to enrich", + default="", + ) + organization_id: Optional[str] = SchemaField( + description="Apollo organization ID of the person's company", + default="", + ) + title: Optional[str] = SchemaField( + description="Job title of the person to enrich", + default="", + ) diff --git a/autogpt_platform/backend/backend/blocks/apollo/organization.py b/autogpt_platform/backend/backend/blocks/apollo/organization.py new file mode 100644 index 000000000000..10abec082559 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/apollo/organization.py @@ -0,0 +1,217 @@ +from backend.blocks.apollo._api import ApolloClient +from backend.blocks.apollo._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + ApolloCredentials, + ApolloCredentialsInput, +) +from backend.blocks.apollo.models import ( + Organization, + PrimaryPhone, + SearchOrganizationsRequest, +) +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import CredentialsField, SchemaField + + +class SearchOrganizationsBlock(Block): + """Search for organizations in Apollo""" + + class Input(BlockSchema): + organization_num_employees_range: list[int] = SchemaField( + description="""The number range of employees working for the company. This enables you to find companies based on headcount. You can add multiple ranges to expand your search results. + +Each range you add needs to be a string, with the upper and lower numbers of the range separated only by a comma.""", + default=[0, 1000000], + ) + + organization_locations: list[str] = SchemaField( + description="""The location of the company headquarters. You can search across cities, US states, and countries. + +If a company has several office locations, results are still based on the headquarters location. For example, if you search chicago but a company's HQ location is in boston, any Boston-based companies will not appearch in your search results, even if they match other parameters. + +To exclude companies based on location, use the organization_not_locations parameter. +""", + default_factory=list, + ) + organizations_not_locations: list[str] = SchemaField( + description="""Exclude companies from search results based on the location of the company headquarters. You can use cities, US states, and countries as locations to exclude. + +This parameter is useful for ensuring you do not prospect in an undesirable territory. For example, if you use ireland as a value, no Ireland-based companies will appear in your search results. +""", + default_factory=list, + ) + q_organization_keyword_tags: list[str] = SchemaField( + description="""Filter search results based on keywords associated with companies. For example, you can enter mining as a value to return only companies that have an association with the mining industry.""", + default_factory=list, + ) + q_organization_name: str = SchemaField( + description="""Filter search results to include a specific company name. + +If the value you enter for this parameter does not match with a company's name, the company will not appear in search results, even if it matches other parameters. Partial matches are accepted. For example, if you filter by the value marketing, a company called NY Marketing Unlimited would still be eligible as a search result, but NY Market Analysis would not be eligible.""", + default="", + advanced=False, + ) + organization_ids: list[str] = SchemaField( + description="""The Apollo IDs for the companies you want to include in your search results. Each company in the Apollo database is assigned a unique ID. + +To find IDs, identify the values for organization_id when you call this endpoint.""", + default_factory=list, + ) + max_results: int = SchemaField( + description="""The maximum number of results to return. If you don't specify this parameter, the default is 100.""", + default=100, + ge=1, + le=50000, + advanced=True, + ) + credentials: ApolloCredentialsInput = CredentialsField( + description="Apollo credentials", + ) + + class Output(BlockSchema): + organizations: list[Organization] = SchemaField( + description="List of organizations found", + default_factory=list, + ) + organization: Organization = SchemaField( + description="Each found organization, one at a time", + ) + error: str = SchemaField( + description="Error message if the search failed", + default="", + ) + + def __init__(self): + super().__init__( + id="3d71270d-599e-4148-9b95-71b35d2f44f0", + description="Search for organizations in Apollo", + categories={BlockCategory.SEARCH}, + input_schema=SearchOrganizationsBlock.Input, + output_schema=SearchOrganizationsBlock.Output, + test_credentials=TEST_CREDENTIALS, + test_input={"query": "Google", "credentials": TEST_CREDENTIALS_INPUT}, + test_output=[ + ( + "organization", + Organization( + id="1", + name="Google", + website_url="https://google.com", + blog_url="https://google.com/blog", + angellist_url="https://angel.co/google", + linkedin_url="https://linkedin.com/company/google", + twitter_url="https://twitter.com/google", + facebook_url="https://facebook.com/google", + primary_phone=PrimaryPhone( + source="google", + number="1234567890", + sanitized_number="1234567890", + ), + languages=["en"], + alexa_ranking=1000, + phone="1234567890", + linkedin_uid="1234567890", + founded_year=2000, + publicly_traded_symbol="GOOGL", + publicly_traded_exchange="NASDAQ", + logo_url="https://google.com/logo.png", + chrunchbase_url="https://chrunchbase.com/google", + primary_domain="google.com", + sanitized_phone="1234567890", + owned_by_organization_id="1", + intent_strength="strong", + show_intent=True, + has_intent_signal_account=True, + intent_signal_account="1", + ), + ), + ( + "organizations", + [ + Organization( + id="1", + name="Google", + website_url="https://google.com", + blog_url="https://google.com/blog", + angellist_url="https://angel.co/google", + linkedin_url="https://linkedin.com/company/google", + twitter_url="https://twitter.com/google", + facebook_url="https://facebook.com/google", + primary_phone=PrimaryPhone( + source="google", + number="1234567890", + sanitized_number="1234567890", + ), + languages=["en"], + alexa_ranking=1000, + phone="1234567890", + linkedin_uid="1234567890", + founded_year=2000, + publicly_traded_symbol="GOOGL", + publicly_traded_exchange="NASDAQ", + logo_url="https://google.com/logo.png", + chrunchbase_url="https://chrunchbase.com/google", + primary_domain="google.com", + sanitized_phone="1234567890", + owned_by_organization_id="1", + intent_strength="strong", + show_intent=True, + has_intent_signal_account=True, + intent_signal_account="1", + ), + ], + ), + ], + test_mock={ + "search_organizations": lambda *args, **kwargs: [ + Organization( + id="1", + name="Google", + website_url="https://google.com", + blog_url="https://google.com/blog", + angellist_url="https://angel.co/google", + linkedin_url="https://linkedin.com/company/google", + twitter_url="https://twitter.com/google", + facebook_url="https://facebook.com/google", + primary_phone=PrimaryPhone( + source="google", + number="1234567890", + sanitized_number="1234567890", + ), + languages=["en"], + alexa_ranking=1000, + phone="1234567890", + linkedin_uid="1234567890", + founded_year=2000, + publicly_traded_symbol="GOOGL", + publicly_traded_exchange="NASDAQ", + logo_url="https://google.com/logo.png", + chrunchbase_url="https://chrunchbase.com/google", + primary_domain="google.com", + sanitized_phone="1234567890", + owned_by_organization_id="1", + intent_strength="strong", + show_intent=True, + has_intent_signal_account=True, + intent_signal_account="1", + ) + ] + }, + ) + + @staticmethod + async def search_organizations( + query: SearchOrganizationsRequest, credentials: ApolloCredentials + ) -> list[Organization]: + client = ApolloClient(credentials) + return await client.search_organizations(query) + + async def run( + self, input_data: Input, *, credentials: ApolloCredentials, **kwargs + ) -> BlockOutput: + query = SearchOrganizationsRequest(**input_data.model_dump()) + organizations = await self.search_organizations(query, credentials) + for organization in organizations: + yield "organization", organization + yield "organizations", organizations diff --git a/autogpt_platform/backend/backend/blocks/apollo/people.py b/autogpt_platform/backend/backend/blocks/apollo/people.py new file mode 100644 index 000000000000..0ef35cd44593 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/apollo/people.py @@ -0,0 +1,363 @@ +import asyncio + +from backend.blocks.apollo._api import ApolloClient +from backend.blocks.apollo._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + ApolloCredentials, + ApolloCredentialsInput, +) +from backend.blocks.apollo.models import ( + Contact, + ContactEmailStatuses, + EnrichPersonRequest, + SearchPeopleRequest, + SenorityLevels, +) +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import CredentialsField, SchemaField + + +class SearchPeopleBlock(Block): + """Search for people in Apollo""" + + class Input(BlockSchema): + person_titles: list[str] = SchemaField( + description="""Job titles held by the people you want to find. For a person to be included in search results, they only need to match 1 of the job titles you add. Adding more job titles expands your search results. + + Results also include job titles with the same terms, even if they are not exact matches. For example, searching for marketing manager might return people with the job title content marketing manager. + + Use this parameter in combination with the person_seniorities[] parameter to find people based on specific job functions and seniority levels. + """, + default_factory=list, + advanced=False, + ) + person_locations: list[str] = SchemaField( + description="""The location where people live. You can search across cities, US states, and countries. + + To find people based on the headquarters locations of their current employer, use the organization_locations parameter.""", + default_factory=list, + advanced=False, + ) + person_seniorities: list[SenorityLevels] = SchemaField( + description="""The job seniority that people hold within their current employer. This enables you to find people that currently hold positions at certain reporting levels, such as Director level or senior IC level. + + For a person to be included in search results, they only need to match 1 of the seniorities you add. Adding more seniorities expands your search results. + + Searches only return results based on their current job title, so searching for Director-level employees only returns people that currently hold a Director-level title. If someone was previously a Director, but is currently a VP, they would not be included in your search results. + + Use this parameter in combination with the person_titles[] parameter to find people based on specific job functions and seniority levels.""", + default_factory=list, + advanced=False, + ) + organization_locations: list[str] = SchemaField( + description="""The location of the company headquarters for a person's current employer. You can search across cities, US states, and countries. + + If a company has several office locations, results are still based on the headquarters location. For example, if you search chicago but a company's HQ location is in boston, people that work for the Boston-based company will not appear in your results, even if they match other parameters. + + To find people based on their personal location, use the person_locations parameter.""", + default_factory=list, + advanced=False, + ) + q_organization_domains: list[str] = SchemaField( + description="""The domain name for the person's employer. This can be the current employer or a previous employer. Do not include www., the @ symbol, or similar. + + You can add multiple domains to search across companies. + + Examples: apollo.io and microsoft.com""", + default_factory=list, + advanced=False, + ) + contact_email_statuses: list[ContactEmailStatuses] = SchemaField( + description="""The email statuses for the people you want to find. You can add multiple statuses to expand your search.""", + default_factory=list, + advanced=False, + ) + organization_ids: list[str] = SchemaField( + description="""The Apollo IDs for the companies (employers) you want to include in your search results. Each company in the Apollo database is assigned a unique ID. + + To find IDs, call the Organization Search endpoint and identify the values for organization_id.""", + default_factory=list, + advanced=False, + ) + organization_num_employees_range: list[int] = SchemaField( + description="""The number range of employees working for the company. This enables you to find companies based on headcount. You can add multiple ranges to expand your search results. + + Each range you add needs to be a string, with the upper and lower numbers of the range separated only by a comma.""", + default_factory=list, + advanced=False, + ) + q_keywords: str = SchemaField( + description="""A string of words over which we want to filter the results""", + default="", + advanced=False, + ) + max_results: int = SchemaField( + description="""The maximum number of results to return. If you don't specify this parameter, the default is 25. Limited to 500 to prevent overspending.""", + default=25, + ge=1, + le=500, + advanced=True, + ) + enrich_info: bool = SchemaField( + description="""Whether to enrich contacts with detailed information including real email addresses. This will double the search cost.""", + default=False, + advanced=True, + ) + + credentials: ApolloCredentialsInput = CredentialsField( + description="Apollo credentials", + ) + + class Output(BlockSchema): + people: list[Contact] = SchemaField( + description="List of people found", + default_factory=list, + ) + error: str = SchemaField( + description="Error message if the search failed", + default="", + ) + + def __init__(self): + super().__init__( + id="c2adb3aa-5aae-488d-8a6e-4eb8c23e2ed6", + description="Search for people in Apollo", + categories={BlockCategory.SEARCH}, + input_schema=SearchPeopleBlock.Input, + output_schema=SearchPeopleBlock.Output, + test_credentials=TEST_CREDENTIALS, + test_input={"credentials": TEST_CREDENTIALS_INPUT}, + test_output=[ + ( + "people", + [ + Contact( + contact_roles=[], + id="1", + name="John Doe", + first_name="John", + last_name="Doe", + linkedin_url="https://www.linkedin.com/in/johndoe", + title="Software Engineer", + organization_name="Google", + organization_id="123456", + contact_stage_id="1", + owner_id="1", + creator_id="1", + person_id="1", + email_needs_tickling=True, + source="apollo", + original_source="apollo", + headline="Software Engineer", + photo_url="https://www.linkedin.com/in/johndoe", + present_raw_address="123 Main St, Anytown, USA", + linkededin_uid="123456", + extrapolated_email_confidence=0.8, + salesforce_id="123456", + salesforce_lead_id="123456", + salesforce_contact_id="123456", + saleforce_account_id="123456", + crm_owner_id="123456", + created_at="2021-01-01", + emailer_campaign_ids=[], + direct_dial_status="active", + direct_dial_enrichment_failed_at="2021-01-01", + email_status="active", + email_source="apollo", + account_id="123456", + last_activity_date="2021-01-01", + hubspot_vid="123456", + hubspot_company_id="123456", + crm_id="123456", + sanitized_phone="123456", + merged_crm_ids="123456", + updated_at="2021-01-01", + queued_for_crm_push=True, + suggested_from_rule_engine_config_id="123456", + email_unsubscribed=None, + label_ids=[], + has_pending_email_arcgate_request=True, + has_email_arcgate_request=True, + existence_level=None, + email=None, + email_from_customer=None, + typed_custom_fields=[], + custom_field_errors=None, + salesforce_record_id=None, + crm_record_url=None, + email_status_unavailable_reason=None, + email_true_status=None, + updated_email_true_status=True, + contact_rule_config_statuses=[], + source_display_name=None, + twitter_url=None, + contact_campaign_statuses=[], + state=None, + city=None, + country=None, + account=None, + contact_emails=[], + organization=None, + employment_history=[], + time_zone=None, + intent_strength=None, + show_intent=True, + phone_numbers=[], + account_phone_note=None, + free_domain=True, + is_likely_to_engage=True, + email_domain_catchall=True, + contact_job_change_event=None, + ), + ], + ), + ], + test_mock={ + "search_people": lambda query, credentials: [ + Contact( + id="1", + name="John Doe", + first_name="John", + last_name="Doe", + linkedin_url="https://www.linkedin.com/in/johndoe", + title="Software Engineer", + organization_name="Google", + organization_id="123456", + contact_stage_id="1", + owner_id="1", + creator_id="1", + person_id="1", + email_needs_tickling=True, + source="apollo", + original_source="apollo", + headline="Software Engineer", + photo_url="https://www.linkedin.com/in/johndoe", + present_raw_address="123 Main St, Anytown, USA", + linkededin_uid="123456", + extrapolated_email_confidence=0.8, + salesforce_id="123456", + salesforce_lead_id="123456", + salesforce_contact_id="123456", + saleforce_account_id="123456", + crm_owner_id="123456", + created_at="2021-01-01", + emailer_campaign_ids=[], + direct_dial_status="active", + direct_dial_enrichment_failed_at="2021-01-01", + email_status="active", + email_source="apollo", + account_id="123456", + last_activity_date="2021-01-01", + hubspot_vid="123456", + hubspot_company_id="123456", + crm_id="123456", + sanitized_phone="123456", + merged_crm_ids="123456", + updated_at="2021-01-01", + queued_for_crm_push=True, + suggested_from_rule_engine_config_id="123456", + email_unsubscribed=None, + label_ids=[], + has_pending_email_arcgate_request=True, + has_email_arcgate_request=True, + existence_level=None, + email=None, + email_from_customer=None, + typed_custom_fields=[], + custom_field_errors=None, + salesforce_record_id=None, + crm_record_url=None, + email_status_unavailable_reason=None, + email_true_status=None, + updated_email_true_status=True, + contact_rule_config_statuses=[], + source_display_name=None, + twitter_url=None, + contact_campaign_statuses=[], + state=None, + city=None, + country=None, + account=None, + contact_emails=[], + organization=None, + employment_history=[], + time_zone=None, + intent_strength=None, + show_intent=True, + phone_numbers=[], + account_phone_note=None, + free_domain=True, + is_likely_to_engage=True, + email_domain_catchall=True, + contact_job_change_event=None, + ), + ] + }, + ) + + @staticmethod + async def search_people( + query: SearchPeopleRequest, credentials: ApolloCredentials + ) -> list[Contact]: + client = ApolloClient(credentials) + return await client.search_people(query) + + @staticmethod + async def enrich_person( + query: EnrichPersonRequest, credentials: ApolloCredentials + ) -> Contact: + client = ApolloClient(credentials) + return await client.enrich_person(query) + + @staticmethod + def merge_contact_data(original: Contact, enriched: Contact) -> Contact: + """ + Merge contact data from original search with enriched data. + Enriched data complements original data, only filling in missing values. + """ + merged_data = original.model_dump() + enriched_data = enriched.model_dump() + + # Only update fields that are None, empty string, empty list, or default values in original + for key, enriched_value in enriched_data.items(): + # Skip if enriched value is None, empty string, or empty list + if enriched_value is None or enriched_value == "" or enriched_value == []: + continue + + # Update if original value is None, empty string, empty list, or zero + if enriched_value: + merged_data[key] = enriched_value + + return Contact(**merged_data) + + async def run( + self, + input_data: Input, + *, + credentials: ApolloCredentials, + **kwargs, + ) -> BlockOutput: + + query = SearchPeopleRequest(**input_data.model_dump()) + people = await self.search_people(query, credentials) + + # Enrich with detailed info if requested + if input_data.enrich_info: + + async def enrich_or_fallback(person: Contact): + try: + enrich_query = EnrichPersonRequest(person_id=person.id) + enriched_person = await self.enrich_person( + enrich_query, credentials + ) + # Merge enriched data with original data, complementing instead of replacing + return self.merge_contact_data(person, enriched_person) + except Exception: + return person # If enrichment fails, use original person data + + people = await asyncio.gather( + *(enrich_or_fallback(person) for person in people) + ) + + yield "people", people diff --git a/autogpt_platform/backend/backend/blocks/apollo/person.py b/autogpt_platform/backend/backend/blocks/apollo/person.py new file mode 100644 index 000000000000..dad8ab733fff --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/apollo/person.py @@ -0,0 +1,138 @@ +from backend.blocks.apollo._api import ApolloClient +from backend.blocks.apollo._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + ApolloCredentials, + ApolloCredentialsInput, +) +from backend.blocks.apollo.models import Contact, EnrichPersonRequest +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import CredentialsField, SchemaField + + +class GetPersonDetailBlock(Block): + """Get detailed person data with Apollo API, including email reveal""" + + class Input(BlockSchema): + person_id: str = SchemaField( + description="Apollo person ID to enrich (most accurate method)", + default="", + advanced=False, + ) + first_name: str = SchemaField( + description="First name of the person to enrich", + default="", + advanced=False, + ) + last_name: str = SchemaField( + description="Last name of the person to enrich", + default="", + advanced=False, + ) + name: str = SchemaField( + description="Full name of the person to enrich (alternative to first_name + last_name)", + default="", + advanced=False, + ) + email: str = SchemaField( + description="Known email address of the person (helps with matching)", + default="", + advanced=False, + ) + domain: str = SchemaField( + description="Company domain of the person (e.g., 'google.com')", + default="", + advanced=False, + ) + company: str = SchemaField( + description="Company name of the person", + default="", + advanced=False, + ) + linkedin_url: str = SchemaField( + description="LinkedIn URL of the person", + default="", + advanced=False, + ) + organization_id: str = SchemaField( + description="Apollo organization ID of the person's company", + default="", + advanced=True, + ) + title: str = SchemaField( + description="Job title of the person to enrich", + default="", + advanced=True, + ) + credentials: ApolloCredentialsInput = CredentialsField( + description="Apollo credentials", + ) + + class Output(BlockSchema): + contact: Contact = SchemaField( + description="Enriched contact information", + ) + error: str = SchemaField( + description="Error message if enrichment failed", + default="", + ) + + def __init__(self): + super().__init__( + id="3b18d46c-3db6-42ae-a228-0ba441bdd176", + description="Get detailed person data with Apollo API, including email reveal", + categories={BlockCategory.SEARCH}, + input_schema=GetPersonDetailBlock.Input, + output_schema=GetPersonDetailBlock.Output, + test_credentials=TEST_CREDENTIALS, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "first_name": "John", + "last_name": "Doe", + "company": "Google", + }, + test_output=[ + ( + "contact", + Contact( + id="1", + name="John Doe", + first_name="John", + last_name="Doe", + email="john.doe@gmail.com", + title="Software Engineer", + organization_name="Google", + linkedin_url="https://www.linkedin.com/in/johndoe", + ), + ), + ], + test_mock={ + "enrich_person": lambda query, credentials: Contact( + id="1", + name="John Doe", + first_name="John", + last_name="Doe", + email="john.doe@gmail.com", + title="Software Engineer", + organization_name="Google", + linkedin_url="https://www.linkedin.com/in/johndoe", + ) + }, + ) + + @staticmethod + async def enrich_person( + query: EnrichPersonRequest, credentials: ApolloCredentials + ) -> Contact: + client = ApolloClient(credentials) + return await client.enrich_person(query) + + async def run( + self, + input_data: Input, + *, + credentials: ApolloCredentials, + **kwargs, + ) -> BlockOutput: + query = EnrichPersonRequest(**input_data.model_dump()) + yield "contact", await self.enrich_person(query, credentials) diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/__init__.py b/autogpt_platform/backend/backend/blocks/ayrshare/__init__.py new file mode 100644 index 000000000000..94a567109e60 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/__init__.py @@ -0,0 +1,15 @@ +AYRSHARE_BLOCK_IDS = [ + "cbd52c2a-06d2-43ed-9560-6576cc163283", # PostToBlueskyBlock + "3352f512-3524-49ed-a08f-003042da2fc1", # PostToFacebookBlock + "9e8f844e-b4a5-4b25-80f2-9e1dd7d67625", # PostToXBlock + "589af4e4-507f-42fd-b9ac-a67ecef25811", # PostToLinkedInBlock + "89b02b96-a7cb-46f4-9900-c48b32fe1552", # PostToInstagramBlock + "0082d712-ff1b-4c3d-8a8d-6c7721883b83", # PostToYouTubeBlock + "c7733580-3c72-483e-8e47-a8d58754d853", # PostToRedditBlock + "47bc74eb-4af2-452c-b933-af377c7287df", # PostToTelegramBlock + "2c38c783-c484-4503-9280-ef5d1d345a7e", # PostToGMBBlock + "3ca46e05-dbaa-4afb-9e95-5a429c4177e6", # PostToPinterestBlock + "7faf4b27-96b0-4f05-bf64-e0de54ae74e1", # PostToTikTokBlock + "f8c3b2e1-9d4a-4e5f-8c7b-6a9e8d2f1c3b", # PostToThreadsBlock + "a9d7f854-2c83-4e96-b3a1-7f2e9c5d4b8e", # PostToSnapchatBlock +] diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/_util.py b/autogpt_platform/backend/backend/blocks/ayrshare/_util.py new file mode 100644 index 000000000000..a647e933df58 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/_util.py @@ -0,0 +1,152 @@ +from datetime import datetime +from typing import Optional + +from pydantic import BaseModel, Field + +from backend.data.block import BlockSchema +from backend.data.model import SchemaField, UserIntegrations +from backend.integrations.ayrshare import AyrshareClient +from backend.util.clients import get_database_manager_async_client +from backend.util.exceptions import MissingConfigError + + +async def get_profile_key(user_id: str): + user_integrations: UserIntegrations = ( + await get_database_manager_async_client().get_user_integrations(user_id) + ) + return user_integrations.managed_credentials.ayrshare_profile_key + + +class BaseAyrshareInput(BlockSchema): + """Base input model for Ayrshare social media posts with common fields.""" + + post: str = SchemaField( + description="The post text to be published", default="", advanced=False + ) + media_urls: list[str] = SchemaField( + description="Optional list of media URLs to include. Set is_video in advanced settings to true if you want to upload videos.", + default_factory=list, + advanced=False, + ) + is_video: bool = SchemaField( + description="Whether the media is a video", default=False, advanced=True + ) + schedule_date: Optional[datetime] = SchemaField( + description="UTC datetime for scheduling (YYYY-MM-DDThh:mm:ssZ)", + default=None, + advanced=True, + ) + disable_comments: bool = SchemaField( + description="Whether to disable comments", default=False, advanced=True + ) + shorten_links: bool = SchemaField( + description="Whether to shorten links", default=False, advanced=True + ) + unsplash: Optional[str] = SchemaField( + description="Unsplash image configuration", default=None, advanced=True + ) + requires_approval: bool = SchemaField( + description="Whether to enable approval workflow", + default=False, + advanced=True, + ) + random_post: bool = SchemaField( + description="Whether to generate random post text", + default=False, + advanced=True, + ) + random_media_url: bool = SchemaField( + description="Whether to generate random media", default=False, advanced=True + ) + notes: Optional[str] = SchemaField( + description="Additional notes for the post", default=None, advanced=True + ) + + +class CarouselItem(BaseModel): + """Model for Facebook carousel items.""" + + name: str = Field(..., description="The name of the item") + link: str = Field(..., description="The link of the item") + picture: str = Field(..., description="The picture URL of the item") + + +class CallToAction(BaseModel): + """Model for Google My Business Call to Action.""" + + action_type: str = Field( + ..., description="Type of action (book, order, shop, learn_more, sign_up, call)" + ) + url: Optional[str] = Field( + description="URL for the action (not required for 'call' action)" + ) + + +class EventDetails(BaseModel): + """Model for Google My Business Event details.""" + + title: str = Field(..., description="Event title") + start_date: str = Field(..., description="Event start date (ISO format)") + end_date: str = Field(..., description="Event end date (ISO format)") + + +class OfferDetails(BaseModel): + """Model for Google My Business Offer details.""" + + title: str = Field(..., description="Offer title") + start_date: str = Field(..., description="Offer start date (ISO format)") + end_date: str = Field(..., description="Offer end date (ISO format)") + coupon_code: str = Field(..., description="Coupon code (max 58 characters)") + redeem_online_url: str = Field(..., description="URL to redeem the offer") + terms_conditions: str = Field(..., description="Terms and conditions") + + +class InstagramUserTag(BaseModel): + """Model for Instagram user tags.""" + + username: str = Field(..., description="Instagram username (without @)") + x: Optional[float] = Field(description="X coordinate (0.0-1.0) for image posts") + y: Optional[float] = Field(description="Y coordinate (0.0-1.0) for image posts") + + +class LinkedInTargeting(BaseModel): + """Model for LinkedIn audience targeting.""" + + countries: Optional[list[str]] = Field( + description="Country codes (e.g., ['US', 'IN', 'DE', 'GB'])" + ) + seniorities: Optional[list[str]] = Field( + description="Seniority levels (e.g., ['Senior', 'VP'])" + ) + degrees: Optional[list[str]] = Field(description="Education degrees") + fields_of_study: Optional[list[str]] = Field(description="Fields of study") + industries: Optional[list[str]] = Field(description="Industry categories") + job_functions: Optional[list[str]] = Field(description="Job function categories") + staff_count_ranges: Optional[list[str]] = Field(description="Company size ranges") + + +class PinterestCarouselOption(BaseModel): + """Model for Pinterest carousel image options.""" + + title: Optional[str] = Field(description="Image title") + link: Optional[str] = Field(description="External destination link for the image") + description: Optional[str] = Field(description="Image description") + + +class YouTubeTargeting(BaseModel): + """Model for YouTube country targeting.""" + + block: Optional[list[str]] = Field( + description="Country codes to block (e.g., ['US', 'CA'])" + ) + allow: Optional[list[str]] = Field( + description="Country codes to allow (e.g., ['GB', 'AU'])" + ) + + +def create_ayrshare_client(): + """Create an Ayrshare client instance.""" + try: + return AyrshareClient() + except MissingConfigError: + return None diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_bluesky.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_bluesky.py new file mode 100644 index 000000000000..0d6eeed0a1ac --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_bluesky.py @@ -0,0 +1,114 @@ +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import BaseAyrshareInput, create_ayrshare_client, get_profile_key + + +class PostToBlueskyBlock(Block): + """Block for posting to Bluesky with Bluesky-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for Bluesky posts.""" + + # Override post field to include character limit information + post: str = SchemaField( + description="The post text to be published (max 300 characters for Bluesky)", + default="", + advanced=False, + ) + + # Override media_urls to include Bluesky-specific constraints + media_urls: list[str] = SchemaField( + description="Optional list of media URLs to include. Bluesky supports up to 4 images or 1 video.", + default_factory=list, + advanced=False, + ) + + # Bluesky-specific options + alt_text: list[str] = SchemaField( + description="Alt text for each media item (accessibility)", + default_factory=list, + advanced=True, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + disabled=True, + id="cbd52c2a-06d2-43ed-9560-6576cc163283", + description="Post to Bluesky using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToBlueskyBlock.Input, + output_schema=PostToBlueskyBlock.Output, + ) + + async def run( + self, + input_data: "PostToBlueskyBlock.Input", + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + """Post to Bluesky with Bluesky-specific options.""" + + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate character limit for Bluesky + if len(input_data.post) > 300: + yield "error", f"Post text exceeds Bluesky's 300 character limit ({len(input_data.post)} characters)" + return + + # Validate media constraints for Bluesky + if len(input_data.media_urls) > 4: + yield "error", "Bluesky supports a maximum of 4 images or 1 video" + return + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + # Build Bluesky-specific options + bluesky_options = {} + if input_data.alt_text: + bluesky_options["altText"] = input_data.alt_text + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.BLUESKY], + media_urls=input_data.media_urls, + is_video=input_data.is_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + bluesky_options=bluesky_options if bluesky_options else None, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_facebook.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_facebook.py new file mode 100644 index 000000000000..dccc443ef90c --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_facebook.py @@ -0,0 +1,212 @@ +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import ( + BaseAyrshareInput, + CarouselItem, + create_ayrshare_client, + get_profile_key, +) + + +class PostToFacebookBlock(Block): + """Block for posting to Facebook with Facebook-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for Facebook posts.""" + + # Facebook-specific options + is_carousel: bool = SchemaField( + description="Whether to post a carousel", default=False, advanced=True + ) + carousel_link: str = SchemaField( + description="The URL for the 'See More At' button in the carousel", + default="", + advanced=True, + ) + carousel_items: list[CarouselItem] = SchemaField( + description="List of carousel items with name, link and picture URLs. Min 2, max 10 items.", + default_factory=list, + advanced=True, + ) + is_reels: bool = SchemaField( + description="Whether to post to Facebook Reels", + default=False, + advanced=True, + ) + reels_title: str = SchemaField( + description="Title for the Reels video (max 255 chars)", + default="", + advanced=True, + ) + reels_thumbnail: str = SchemaField( + description="Thumbnail URL for Reels video (JPEG/PNG, <10MB)", + default="", + advanced=True, + ) + is_story: bool = SchemaField( + description="Whether to post as a Facebook Story", + default=False, + advanced=True, + ) + media_captions: list[str] = SchemaField( + description="Captions for each media item", + default_factory=list, + advanced=True, + ) + location_id: str = SchemaField( + description="Facebook Page ID or name for location tagging", + default="", + advanced=True, + ) + age_min: int = SchemaField( + description="Minimum age for audience targeting (13,15,18,21,25)", + default=0, + advanced=True, + ) + target_countries: list[str] = SchemaField( + description="List of country codes to target (max 25)", + default_factory=list, + advanced=True, + ) + alt_text: list[str] = SchemaField( + description="Alt text for each media item", + default_factory=list, + advanced=True, + ) + video_title: str = SchemaField( + description="Title for video post", default="", advanced=True + ) + video_thumbnail: str = SchemaField( + description="Thumbnail URL for video post", default="", advanced=True + ) + is_draft: bool = SchemaField( + description="Save as draft in Meta Business Suite", + default=False, + advanced=True, + ) + scheduled_publish_date: str = SchemaField( + description="Schedule publish time in Meta Business Suite (UTC)", + default="", + advanced=True, + ) + preview_link: str = SchemaField( + description="URL for custom link preview", default="", advanced=True + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + disabled=True, + id="3352f512-3524-49ed-a08f-003042da2fc1", + description="Post to Facebook using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToFacebookBlock.Input, + output_schema=PostToFacebookBlock.Output, + ) + + async def run( + self, + input_data: "PostToFacebookBlock.Input", + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + """Post to Facebook with Facebook-specific options.""" + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + # Build Facebook-specific options + facebook_options = {} + if input_data.is_carousel: + facebook_options["isCarousel"] = True + if input_data.carousel_link: + facebook_options["carouselLink"] = input_data.carousel_link + if input_data.carousel_items: + facebook_options["carouselItems"] = [ + item.dict() for item in input_data.carousel_items + ] + + if input_data.is_reels: + facebook_options["isReels"] = True + if input_data.reels_title: + facebook_options["reelsTitle"] = input_data.reels_title + if input_data.reels_thumbnail: + facebook_options["reelsThumbnail"] = input_data.reels_thumbnail + + if input_data.is_story: + facebook_options["isStory"] = True + + if input_data.media_captions: + facebook_options["mediaCaptions"] = input_data.media_captions + + if input_data.location_id: + facebook_options["locationId"] = input_data.location_id + + if input_data.age_min > 0: + facebook_options["ageMin"] = input_data.age_min + + if input_data.target_countries: + facebook_options["targetCountries"] = input_data.target_countries + + if input_data.alt_text: + facebook_options["altText"] = input_data.alt_text + + if input_data.video_title: + facebook_options["videoTitle"] = input_data.video_title + + if input_data.video_thumbnail: + facebook_options["videoThumbnail"] = input_data.video_thumbnail + + if input_data.is_draft: + facebook_options["isDraft"] = True + + if input_data.scheduled_publish_date: + facebook_options["scheduledPublishDate"] = input_data.scheduled_publish_date + + if input_data.preview_link: + facebook_options["previewLink"] = input_data.preview_link + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.FACEBOOK], + media_urls=input_data.media_urls, + is_video=input_data.is_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + facebook_options=facebook_options if facebook_options else None, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_gmb.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_gmb.py new file mode 100644 index 000000000000..5c510cccb317 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_gmb.py @@ -0,0 +1,210 @@ +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import BaseAyrshareInput, create_ayrshare_client, get_profile_key + + +class PostToGMBBlock(Block): + """Block for posting to Google My Business with GMB-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for Google My Business posts.""" + + # Override media_urls to include GMB-specific constraints + media_urls: list[str] = SchemaField( + description="Optional list of media URLs. GMB supports only one image or video per post.", + default_factory=list, + advanced=False, + ) + + # GMB-specific options + is_photo_video: bool = SchemaField( + description="Whether this is a photo/video post (appears in Photos section)", + default=False, + advanced=True, + ) + photo_category: str = SchemaField( + description="Category for photo/video: cover, profile, logo, exterior, interior, product, at_work, food_and_drink, menu, common_area, rooms, teams", + default="", + advanced=True, + ) + # Call to action options (flattened from CallToAction object) + call_to_action_type: str = SchemaField( + description="Type of action button: 'book', 'order', 'shop', 'learn_more', 'sign_up', or 'call'", + default="", + advanced=True, + ) + call_to_action_url: str = SchemaField( + description="URL for the action button (not required for 'call' action)", + default="", + advanced=True, + ) + # Event details options (flattened from EventDetails object) + event_title: str = SchemaField( + description="Event title for event posts", + default="", + advanced=True, + ) + event_start_date: str = SchemaField( + description="Event start date in ISO format (e.g., '2024-03-15T09:00:00Z')", + default="", + advanced=True, + ) + event_end_date: str = SchemaField( + description="Event end date in ISO format (e.g., '2024-03-15T17:00:00Z')", + default="", + advanced=True, + ) + # Offer details options (flattened from OfferDetails object) + offer_title: str = SchemaField( + description="Offer title for promotional posts", + default="", + advanced=True, + ) + offer_start_date: str = SchemaField( + description="Offer start date in ISO format (e.g., '2024-03-15T00:00:00Z')", + default="", + advanced=True, + ) + offer_end_date: str = SchemaField( + description="Offer end date in ISO format (e.g., '2024-04-15T23:59:59Z')", + default="", + advanced=True, + ) + offer_coupon_code: str = SchemaField( + description="Coupon code for the offer (max 58 characters)", + default="", + advanced=True, + ) + offer_redeem_online_url: str = SchemaField( + description="URL where customers can redeem the offer online", + default="", + advanced=True, + ) + offer_terms_conditions: str = SchemaField( + description="Terms and conditions for the offer", + default="", + advanced=True, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + disabled=True, + id="2c38c783-c484-4503-9280-ef5d1d345a7e", + description="Post to Google My Business using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToGMBBlock.Input, + output_schema=PostToGMBBlock.Output, + ) + + async def run( + self, input_data: "PostToGMBBlock.Input", *, user_id: str, **kwargs + ) -> BlockOutput: + """Post to Google My Business with GMB-specific options.""" + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate GMB constraints + if len(input_data.media_urls) > 1: + yield "error", "Google My Business supports only one image or video per post" + return + + # Validate offer coupon code length + if input_data.offer_coupon_code and len(input_data.offer_coupon_code) > 58: + yield "error", "GMB offer coupon code cannot exceed 58 characters" + return + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + # Build GMB-specific options + gmb_options = {} + + # Photo/Video post options + if input_data.is_photo_video: + gmb_options["isPhotoVideo"] = True + if input_data.photo_category: + gmb_options["category"] = input_data.photo_category + + # Call to Action (from flattened fields) + if input_data.call_to_action_type: + cta_dict = {"actionType": input_data.call_to_action_type} + # URL not required for 'call' action type + if ( + input_data.call_to_action_type != "call" + and input_data.call_to_action_url + ): + cta_dict["url"] = input_data.call_to_action_url + gmb_options["callToAction"] = cta_dict + + # Event details (from flattened fields) + if ( + input_data.event_title + and input_data.event_start_date + and input_data.event_end_date + ): + gmb_options["event"] = { + "title": input_data.event_title, + "startDate": input_data.event_start_date, + "endDate": input_data.event_end_date, + } + + # Offer details (from flattened fields) + if ( + input_data.offer_title + and input_data.offer_start_date + and input_data.offer_end_date + and input_data.offer_coupon_code + and input_data.offer_redeem_online_url + and input_data.offer_terms_conditions + ): + gmb_options["offer"] = { + "title": input_data.offer_title, + "startDate": input_data.offer_start_date, + "endDate": input_data.offer_end_date, + "couponCode": input_data.offer_coupon_code, + "redeemOnlineUrl": input_data.offer_redeem_online_url, + "termsConditions": input_data.offer_terms_conditions, + } + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.GOOGLE_MY_BUSINESS], + media_urls=input_data.media_urls, + is_video=input_data.is_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + gmb_options=gmb_options if gmb_options else None, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_instagram.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_instagram.py new file mode 100644 index 000000000000..1fc7c77df019 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_instagram.py @@ -0,0 +1,249 @@ +from typing import Any + +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import ( + BaseAyrshareInput, + InstagramUserTag, + create_ayrshare_client, + get_profile_key, +) + + +class PostToInstagramBlock(Block): + """Block for posting to Instagram with Instagram-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for Instagram posts.""" + + # Override post field to include Instagram-specific information + post: str = SchemaField( + description="The post text (max 2,200 chars, up to 30 hashtags, 3 @mentions)", + default="", + advanced=False, + ) + + # Override media_urls to include Instagram-specific constraints + media_urls: list[str] = SchemaField( + description="Optional list of media URLs. Instagram supports up to 10 images/videos in a carousel.", + default_factory=list, + advanced=False, + ) + + # Instagram-specific options + is_story: bool | None = SchemaField( + description="Whether to post as Instagram Story (24-hour expiration)", + default=None, + advanced=True, + ) + + # ------- REELS OPTIONS ------- + share_reels_feed: bool | None = SchemaField( + description="Whether Reel should appear in both Feed and Reels tabs", + default=None, + advanced=True, + ) + audio_name: str | None = SchemaField( + description="Audio name for Reels (e.g., 'The Weeknd - Blinding Lights')", + default=None, + advanced=True, + ) + thumbnail: str | None = SchemaField( + description="Thumbnail URL for Reel video", default=None, advanced=True + ) + thumbnail_offset: int | None = SchemaField( + description="Thumbnail frame offset in milliseconds (default: 0)", + default=0, + advanced=True, + ) + + # ------- POST OPTIONS ------- + + alt_text: list[str] = SchemaField( + description="Alt text for each media item (up to 1,000 chars each, accessibility feature), each item in the list corresponds to a media item in the media_urls list", + default_factory=list, + advanced=True, + ) + + location_id: str | None = SchemaField( + description="Facebook Page ID or name for location tagging (e.g., '7640348500' or '@guggenheimmuseum')", + default=None, + advanced=True, + ) + user_tags: list[dict[str, Any]] = SchemaField( + description="List of users to tag with coordinates for images", + default_factory=list, + advanced=True, + ) + collaborators: list[str] = SchemaField( + description="Instagram usernames to invite as collaborators (max 3, public accounts only)", + default_factory=list, + advanced=True, + ) + auto_resize: bool | None = SchemaField( + description="Auto-resize images to 1080x1080px for Instagram", + default=None, + advanced=True, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + id="89b02b96-a7cb-46f4-9900-c48b32fe1552", + description="Post to Instagram using Ayrshare. Requires a Business or Creator Instagram Account connected with a Facebook Page", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToInstagramBlock.Input, + output_schema=PostToInstagramBlock.Output, + ) + + async def run( + self, + input_data: "PostToInstagramBlock.Input", + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + """Post to Instagram with Instagram-specific options.""" + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate Instagram constraints + if len(input_data.post) > 2200: + yield "error", f"Instagram post text exceeds 2,200 character limit ({len(input_data.post)} characters)" + return + + if len(input_data.media_urls) > 10: + yield "error", "Instagram supports a maximum of 10 images/videos in a carousel" + return + + if len(input_data.collaborators) > 3: + yield "error", "Instagram supports a maximum of 3 collaborators" + return + + # Validate that if any reel option is set, all required reel options are set + reel_options = [ + input_data.share_reels_feed, + input_data.audio_name, + input_data.thumbnail, + ] + + if any(reel_options) and not all(reel_options): + yield "error", "When posting a reel, all reel options must be set: share_reels_feed, audio_name, and either thumbnail or thumbnail_offset" + return + + # Count hashtags and mentions + hashtag_count = input_data.post.count("#") + mention_count = input_data.post.count("@") + + if hashtag_count > 30: + yield "error", f"Instagram allows maximum 30 hashtags ({hashtag_count} found)" + return + + if mention_count > 3: + yield "error", f"Instagram allows maximum 3 @mentions ({mention_count} found)" + return + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + # Build Instagram-specific options + instagram_options = {} + + # Stories + if input_data.is_story: + instagram_options["stories"] = True + + # Reels options + if input_data.share_reels_feed is not None: + instagram_options["shareReelsFeed"] = input_data.share_reels_feed + + if input_data.audio_name: + instagram_options["audioName"] = input_data.audio_name + + if input_data.thumbnail: + instagram_options["thumbNail"] = input_data.thumbnail + elif input_data.thumbnail_offset and input_data.thumbnail_offset > 0: + instagram_options["thumbNailOffset"] = input_data.thumbnail_offset + + # Alt text + if input_data.alt_text: + # Validate alt text length + for i, alt in enumerate(input_data.alt_text): + if len(alt) > 1000: + yield "error", f"Alt text {i+1} exceeds 1,000 character limit ({len(alt)} characters)" + return + instagram_options["altText"] = input_data.alt_text + + # Location + if input_data.location_id: + instagram_options["locationId"] = input_data.location_id + + # User tags + if input_data.user_tags: + user_tags_list = [] + for tag in input_data.user_tags: + try: + tag_obj = InstagramUserTag(**tag) + except Exception as e: + yield "error", f"Invalid user tag: {e}, tages need to be a dictionary with a 3 items: username (str), x (float) and y (float)" + return + tag_dict: dict[str, float | str] = {"username": tag_obj.username} + if tag_obj.x is not None and tag_obj.y is not None: + # Validate coordinates + if not (0.0 <= tag_obj.x <= 1.0) or not (0.0 <= tag_obj.y <= 1.0): + yield "error", f"User tag coordinates must be between 0.0 and 1.0 (user: {tag_obj.username})" + return + tag_dict["x"] = tag_obj.x + tag_dict["y"] = tag_obj.y + user_tags_list.append(tag_dict) + instagram_options["userTags"] = user_tags_list + + # Collaborators + if input_data.collaborators: + instagram_options["collaborators"] = input_data.collaborators + + # Auto resize + if input_data.auto_resize: + instagram_options["autoResize"] = True + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.INSTAGRAM], + media_urls=input_data.media_urls, + is_video=input_data.is_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + instagram_options=instagram_options if instagram_options else None, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_linkedin.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_linkedin.py new file mode 100644 index 000000000000..7fad89e838bc --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_linkedin.py @@ -0,0 +1,222 @@ +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import BaseAyrshareInput, create_ayrshare_client, get_profile_key + + +class PostToLinkedInBlock(Block): + """Block for posting to LinkedIn with LinkedIn-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for LinkedIn posts.""" + + # Override post field to include LinkedIn-specific information + post: str = SchemaField( + description="The post text (max 3,000 chars, hashtags supported with #)", + default="", + advanced=False, + ) + + # Override media_urls to include LinkedIn-specific constraints + media_urls: list[str] = SchemaField( + description="Optional list of media URLs. LinkedIn supports up to 9 images, videos, or documents (PPT, PPTX, DOC, DOCX, PDF <100MB, <300 pages).", + default_factory=list, + advanced=False, + ) + + # LinkedIn-specific options + visibility: str = SchemaField( + description="Post visibility: 'public' (default), 'connections' (personal only), 'loggedin'", + default="public", + advanced=True, + ) + alt_text: list[str] = SchemaField( + description="Alt text for each image (accessibility feature, not supported for videos/documents)", + default_factory=list, + advanced=True, + ) + titles: list[str] = SchemaField( + description="Title/caption for each image or video", + default_factory=list, + advanced=True, + ) + document_title: str = SchemaField( + description="Title for document posts (max 400 chars, uses filename if not specified)", + default="", + advanced=True, + ) + thumbnail: str = SchemaField( + description="Thumbnail URL for video (PNG/JPG, same dimensions as video, <10MB)", + default="", + advanced=True, + ) + # LinkedIn targeting options (flattened from LinkedInTargeting object) + targeting_countries: list[str] | None = SchemaField( + description="Country codes for targeting (e.g., ['US', 'IN', 'DE', 'GB']). Requires 300+ followers in target audience.", + default=None, + advanced=True, + ) + targeting_seniorities: list[str] | None = SchemaField( + description="Seniority levels for targeting (e.g., ['Senior', 'VP']). Requires 300+ followers in target audience.", + default=None, + advanced=True, + ) + targeting_degrees: list[str] | None = SchemaField( + description="Education degrees for targeting. Requires 300+ followers in target audience.", + default=None, + advanced=True, + ) + targeting_fields_of_study: list[str] | None = SchemaField( + description="Fields of study for targeting. Requires 300+ followers in target audience.", + default=None, + advanced=True, + ) + targeting_industries: list[str] | None = SchemaField( + description="Industry categories for targeting. Requires 300+ followers in target audience.", + default=None, + advanced=True, + ) + targeting_job_functions: list[str] | None = SchemaField( + description="Job function categories for targeting. Requires 300+ followers in target audience.", + default=None, + advanced=True, + ) + targeting_staff_count_ranges: list[str] | None = SchemaField( + description="Company size ranges for targeting. Requires 300+ followers in target audience.", + default=None, + advanced=True, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + id="589af4e4-507f-42fd-b9ac-a67ecef25811", + description="Post to LinkedIn using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToLinkedInBlock.Input, + output_schema=PostToLinkedInBlock.Output, + ) + + async def run( + self, + input_data: "PostToLinkedInBlock.Input", + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + """Post to LinkedIn with LinkedIn-specific options.""" + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate LinkedIn constraints + if len(input_data.post) > 3000: + yield "error", f"LinkedIn post text exceeds 3,000 character limit ({len(input_data.post)} characters)" + return + + if len(input_data.media_urls) > 9: + yield "error", "LinkedIn supports a maximum of 9 images/videos/documents" + return + + if input_data.document_title and len(input_data.document_title) > 400: + yield "error", f"LinkedIn document title exceeds 400 character limit ({len(input_data.document_title)} characters)" + return + + # Validate visibility option + valid_visibility = ["public", "connections", "loggedin"] + if input_data.visibility not in valid_visibility: + yield "error", f"LinkedIn visibility must be one of: {', '.join(valid_visibility)}" + return + + # Check for document extensions + document_extensions = [".ppt", ".pptx", ".doc", ".docx", ".pdf"] + has_documents = any( + any(url.lower().endswith(ext) for ext in document_extensions) + for url in input_data.media_urls + ) + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + # Build LinkedIn-specific options + linkedin_options = {} + + # Visibility + if input_data.visibility != "public": + linkedin_options["visibility"] = input_data.visibility + + # Alt text (not supported for videos or documents) + if input_data.alt_text and not has_documents: + linkedin_options["altText"] = input_data.alt_text + + # Titles/captions + if input_data.titles: + linkedin_options["titles"] = input_data.titles + + # Document title + if input_data.document_title and has_documents: + linkedin_options["title"] = input_data.document_title + + # Video thumbnail + if input_data.thumbnail: + linkedin_options["thumbNail"] = input_data.thumbnail + + # Audience targeting (from flattened fields) + targeting_dict = {} + if input_data.targeting_countries: + targeting_dict["countries"] = input_data.targeting_countries + if input_data.targeting_seniorities: + targeting_dict["seniorities"] = input_data.targeting_seniorities + if input_data.targeting_degrees: + targeting_dict["degrees"] = input_data.targeting_degrees + if input_data.targeting_fields_of_study: + targeting_dict["fieldsOfStudy"] = input_data.targeting_fields_of_study + if input_data.targeting_industries: + targeting_dict["industries"] = input_data.targeting_industries + if input_data.targeting_job_functions: + targeting_dict["jobFunctions"] = input_data.targeting_job_functions + if input_data.targeting_staff_count_ranges: + targeting_dict["staffCountRanges"] = input_data.targeting_staff_count_ranges + + if targeting_dict: + linkedin_options["targeting"] = targeting_dict + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.LINKEDIN], + media_urls=input_data.media_urls, + is_video=input_data.is_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + linkedin_options=linkedin_options if linkedin_options else None, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_pinterest.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_pinterest.py new file mode 100644 index 000000000000..55bae8161890 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_pinterest.py @@ -0,0 +1,214 @@ +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import ( + BaseAyrshareInput, + PinterestCarouselOption, + create_ayrshare_client, + get_profile_key, +) + + +class PostToPinterestBlock(Block): + """Block for posting to Pinterest with Pinterest-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for Pinterest posts.""" + + # Override post field to include Pinterest-specific information + post: str = SchemaField( + description="Pin description (max 500 chars, links not clickable - use link field instead)", + default="", + advanced=False, + ) + + # Override media_urls to include Pinterest-specific constraints + media_urls: list[str] = SchemaField( + description="Required image/video URLs. Pinterest requires at least one image. Videos need thumbnail. Up to 5 images for carousel.", + default_factory=list, + advanced=False, + ) + + # Pinterest-specific options + pin_title: str = SchemaField( + description="Pin title displayed in 'Add your title' section (max 100 chars)", + default="", + advanced=True, + ) + link: str = SchemaField( + description="Clickable destination URL when users click the pin (max 2048 chars)", + default="", + advanced=True, + ) + board_id: str = SchemaField( + description="Pinterest Board ID to post to (from /user/details endpoint, uses default board if not specified)", + default="", + advanced=True, + ) + note: str = SchemaField( + description="Private note for the pin (only visible to you and board collaborators)", + default="", + advanced=True, + ) + thumbnail: str = SchemaField( + description="Required thumbnail URL for video pins (must have valid image Content-Type)", + default="", + advanced=True, + ) + carousel_options: list[PinterestCarouselOption] = SchemaField( + description="Options for each image in carousel (title, link, description per image)", + default_factory=list, + advanced=True, + ) + alt_text: list[str] = SchemaField( + description="Alt text for each image/video (max 500 chars each, accessibility feature)", + default_factory=list, + advanced=True, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + disabled=True, + id="3ca46e05-dbaa-4afb-9e95-5a429c4177e6", + description="Post to Pinterest using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToPinterestBlock.Input, + output_schema=PostToPinterestBlock.Output, + ) + + async def run( + self, + input_data: "PostToPinterestBlock.Input", + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + """Post to Pinterest with Pinterest-specific options.""" + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate Pinterest constraints + if len(input_data.post) > 500: + yield "error", f"Pinterest pin description exceeds 500 character limit ({len(input_data.post)} characters)" + return + + if len(input_data.pin_title) > 100: + yield "error", f"Pinterest pin title exceeds 100 character limit ({len(input_data.pin_title)} characters)" + return + + if len(input_data.link) > 2048: + yield "error", f"Pinterest link URL exceeds 2048 character limit ({len(input_data.link)} characters)" + return + + if len(input_data.media_urls) == 0: + yield "error", "Pinterest requires at least one image or video" + return + + if len(input_data.media_urls) > 5: + yield "error", "Pinterest supports a maximum of 5 images in a carousel" + return + + # Check if video is included and thumbnail is provided + video_extensions = [".mp4", ".mov", ".avi", ".mkv", ".wmv", ".flv", ".webm"] + has_video = any( + any(url.lower().endswith(ext) for ext in video_extensions) + for url in input_data.media_urls + ) + + if (has_video or input_data.is_video) and not input_data.thumbnail: + yield "error", "Pinterest video pins require a thumbnail URL" + return + + # Validate alt text length + for i, alt in enumerate(input_data.alt_text): + if len(alt) > 500: + yield "error", f"Pinterest alt text {i+1} exceeds 500 character limit ({len(alt)} characters)" + return + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + # Build Pinterest-specific options + pinterest_options = {} + + # Pin title + if input_data.pin_title: + pinterest_options["title"] = input_data.pin_title + + # Clickable link + if input_data.link: + pinterest_options["link"] = input_data.link + + # Board ID + if input_data.board_id: + pinterest_options["boardId"] = input_data.board_id + + # Private note + if input_data.note: + pinterest_options["note"] = input_data.note + + # Video thumbnail + if input_data.thumbnail: + pinterest_options["thumbNail"] = input_data.thumbnail + + # Carousel options + if input_data.carousel_options: + carousel_list = [] + for option in input_data.carousel_options: + carousel_dict = {} + if option.title: + carousel_dict["title"] = option.title + if option.link: + carousel_dict["link"] = option.link + if option.description: + carousel_dict["description"] = option.description + if carousel_dict: # Only add if not empty + carousel_list.append(carousel_dict) + if carousel_list: + pinterest_options["carouselOptions"] = carousel_list + + # Alt text + if input_data.alt_text: + pinterest_options["altText"] = input_data.alt_text + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.PINTEREST], + media_urls=input_data.media_urls, + is_video=input_data.is_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + pinterest_options=pinterest_options if pinterest_options else None, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_reddit.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_reddit.py new file mode 100644 index 000000000000..c193f94d2ede --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_reddit.py @@ -0,0 +1,69 @@ +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import BaseAyrshareInput, create_ayrshare_client, get_profile_key + + +class PostToRedditBlock(Block): + """Block for posting to Reddit.""" + + class Input(BaseAyrshareInput): + """Input schema for Reddit posts.""" + + pass # Uses all base fields + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + disabled=True, + id="c7733580-3c72-483e-8e47-a8d58754d853", + description="Post to Reddit using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToRedditBlock.Input, + output_schema=PostToRedditBlock.Output, + ) + + async def run( + self, input_data: "PostToRedditBlock.Input", *, user_id: str, **kwargs + ) -> BlockOutput: + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured." + return + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.REDDIT], + media_urls=input_data.media_urls, + is_video=input_data.is_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_snapchat.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_snapchat.py new file mode 100644 index 000000000000..8de728e56917 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_snapchat.py @@ -0,0 +1,129 @@ +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import BaseAyrshareInput, create_ayrshare_client, get_profile_key + + +class PostToSnapchatBlock(Block): + """Block for posting to Snapchat with Snapchat-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for Snapchat posts.""" + + # Override post field to include Snapchat-specific information + post: str = SchemaField( + description="The post text (optional for video-only content)", + default="", + advanced=False, + ) + + # Override media_urls to include Snapchat-specific constraints + media_urls: list[str] = SchemaField( + description="Required video URL for Snapchat posts. Snapchat only supports video content.", + default_factory=list, + advanced=False, + ) + + # Snapchat-specific options + story_type: str = SchemaField( + description="Type of Snapchat content: 'story' (24-hour Stories), 'saved_story' (Saved Stories), or 'spotlight' (Spotlight posts)", + default="story", + advanced=True, + ) + video_thumbnail: str = SchemaField( + description="Thumbnail URL for video content (optional, auto-generated if not provided)", + default="", + advanced=True, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + disabled=True, + id="a9d7f854-2c83-4e96-b3a1-7f2e9c5d4b8e", + description="Post to Snapchat using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToSnapchatBlock.Input, + output_schema=PostToSnapchatBlock.Output, + ) + + async def run( + self, + input_data: "PostToSnapchatBlock.Input", + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + """Post to Snapchat with Snapchat-specific options.""" + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate Snapchat constraints + if not input_data.media_urls: + yield "error", "Snapchat requires at least one video URL" + return + + if len(input_data.media_urls) > 1: + yield "error", "Snapchat supports only one video per post" + return + + # Validate story type + valid_story_types = ["story", "saved_story", "spotlight"] + if input_data.story_type not in valid_story_types: + yield "error", f"Snapchat story type must be one of: {', '.join(valid_story_types)}" + return + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + # Build Snapchat-specific options + snapchat_options = {} + + # Story type + if input_data.story_type != "story": + snapchat_options["storyType"] = input_data.story_type + + # Video thumbnail + if input_data.video_thumbnail: + snapchat_options["videoThumbnail"] = input_data.video_thumbnail + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.SNAPCHAT], + media_urls=input_data.media_urls, + is_video=True, # Snapchat only supports video + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + snapchat_options=snapchat_options if snapchat_options else None, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_telegram.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_telegram.py new file mode 100644 index 000000000000..a18d9a6cb11b --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_telegram.py @@ -0,0 +1,116 @@ +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import BaseAyrshareInput, create_ayrshare_client, get_profile_key + + +class PostToTelegramBlock(Block): + """Block for posting to Telegram with Telegram-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for Telegram posts.""" + + # Override post field to include Telegram-specific information + post: str = SchemaField( + description="The post text (empty string allowed). Use @handle to mention other Telegram users.", + default="", + advanced=False, + ) + + # Override media_urls to include Telegram-specific constraints + media_urls: list[str] = SchemaField( + description="Optional list of media URLs. For animated GIFs, only one URL is allowed. Telegram will auto-preview links unless image/video is included.", + default_factory=list, + advanced=False, + ) + + # Override is_video to include GIF-specific information + is_video: bool = SchemaField( + description="Whether the media is a video. Set to true for animated GIFs that don't end in .gif/.GIF extension.", + default=False, + advanced=True, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + disabled=True, + id="47bc74eb-4af2-452c-b933-af377c7287df", + description="Post to Telegram using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToTelegramBlock.Input, + output_schema=PostToTelegramBlock.Output, + ) + + async def run( + self, + input_data: "PostToTelegramBlock.Input", + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + """Post to Telegram with Telegram-specific validation.""" + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate Telegram constraints + # Check for animated GIFs - only one URL allowed + gif_extensions = [".gif", ".GIF"] + has_gif = any( + any(url.endswith(ext) for ext in gif_extensions) + for url in input_data.media_urls + ) + + if has_gif and len(input_data.media_urls) > 1: + yield "error", "Telegram animated GIFs support only one URL per post" + return + + # Auto-detect if we need to set is_video for GIFs without proper extension + detected_is_video = input_data.is_video + if input_data.media_urls and not has_gif and not input_data.is_video: + # Check if this might be a GIF without proper extension + # This is just informational - user needs to set is_video manually + pass + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.TELEGRAM], + media_urls=input_data.media_urls, + is_video=detected_is_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_threads.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_threads.py new file mode 100644 index 000000000000..6fdf06f1a58e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_threads.py @@ -0,0 +1,111 @@ +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import BaseAyrshareInput, create_ayrshare_client, get_profile_key + + +class PostToThreadsBlock(Block): + """Block for posting to Threads with Threads-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for Threads posts.""" + + # Override post field to include Threads-specific information + post: str = SchemaField( + description="The post text (max 500 chars, empty string allowed). Only 1 hashtag allowed. Use @handle to mention users.", + default="", + advanced=False, + ) + + # Override media_urls to include Threads-specific constraints + media_urls: list[str] = SchemaField( + description="Optional list of media URLs. Supports up to 20 images/videos in a carousel. Auto-preview links unless media is included.", + default_factory=list, + advanced=False, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + disabled=True, + id="f8c3b2e1-9d4a-4e5f-8c7b-6a9e8d2f1c3b", + description="Post to Threads using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToThreadsBlock.Input, + output_schema=PostToThreadsBlock.Output, + ) + + async def run( + self, + input_data: "PostToThreadsBlock.Input", + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + """Post to Threads with Threads-specific validation.""" + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate Threads constraints + if len(input_data.post) > 500: + yield "error", f"Threads post text exceeds 500 character limit ({len(input_data.post)} characters)" + return + + if len(input_data.media_urls) > 20: + yield "error", "Threads supports a maximum of 20 images/videos in a carousel" + return + + # Count hashtags (only 1 allowed) + hashtag_count = input_data.post.count("#") + if hashtag_count > 1: + yield "error", f"Threads allows only 1 hashtag per post ({hashtag_count} found)" + return + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + # Build Threads-specific options + threads_options = {} + # Note: Based on the documentation, Threads doesn't seem to have specific options + # beyond the standard ones. The main constraints are validation-based. + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.THREADS], + media_urls=input_data.media_urls, + is_video=input_data.is_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + threads_options=threads_options if threads_options else None, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_tiktok.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_tiktok.py new file mode 100644 index 000000000000..581e65b74ee5 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_tiktok.py @@ -0,0 +1,243 @@ +from enum import Enum + +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import BaseAyrshareInput, create_ayrshare_client, get_profile_key + + +class TikTokVisibility(str, Enum): + PUBLIC = "public" + PRIVATE = "private" + FOLLOWERS = "followers" + + +class PostToTikTokBlock(Block): + """Block for posting to TikTok with TikTok-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for TikTok posts.""" + + # Override post field to include TikTok-specific information + post: str = SchemaField( + description="The post text (max 2,200 chars, empty string allowed). Use @handle to mention users. Line breaks will be ignored.", + advanced=False, + ) + + # Override media_urls to include TikTok-specific constraints + media_urls: list[str] = SchemaField( + description="Required media URLs. Either 1 video OR up to 35 images (JPG/JPEG/WEBP only). Cannot mix video and images.", + default_factory=list, + advanced=False, + ) + + # TikTok-specific options + auto_add_music: bool = SchemaField( + description="Whether to automatically add recommended music to the post. If you set this field to true, you can change the music later in the TikTok app.", + default=False, + advanced=True, + ) + disable_comments: bool = SchemaField( + description="Disable comments on the published post", + default=False, + advanced=True, + ) + disable_duet: bool = SchemaField( + description="Disable duets on published video (video only)", + default=False, + advanced=True, + ) + disable_stitch: bool = SchemaField( + description="Disable stitch on published video (video only)", + default=False, + advanced=True, + ) + is_ai_generated: bool = SchemaField( + description="If you enable the toggle, your video will be labeled as “Creator labeled as AI-generated” once posted and can’t be changed. The “Creator labeled as AI-generated” label indicates that the content was completely AI-generated or significantly edited with AI.", + default=False, + advanced=True, + ) + is_branded_content: bool = SchemaField( + description="Whether to enable the Branded Content toggle. If this field is set to true, the video will be labeled as Branded Content, indicating you are in a paid partnership with a brand. A “Paid partnership” label will be attached to the video.", + default=False, + advanced=True, + ) + is_brand_organic: bool = SchemaField( + description="Whether to enable the Brand Organic Content toggle. If this field is set to true, the video will be labeled as Brand Organic Content, indicating you are promoting yourself or your own business. A “Promotional content” label will be attached to the video.", + default=False, + advanced=True, + ) + image_cover_index: int = SchemaField( + description="Index of image to use as cover (0-based, image posts only)", + default=0, + advanced=True, + ) + title: str = SchemaField( + description="Title for image posts", default="", advanced=True + ) + thumbnail_offset: int = SchemaField( + description="Video thumbnail frame offset in milliseconds (video only)", + default=0, + advanced=True, + ) + visibility: TikTokVisibility = SchemaField( + description="Post visibility: 'public', 'private', 'followers', or 'friends'", + default=TikTokVisibility.PUBLIC, + advanced=True, + ) + draft: bool = SchemaField( + description="Create as draft post (video only)", + default=False, + advanced=True, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + id="7faf4b27-96b0-4f05-bf64-e0de54ae74e1", + description="Post to TikTok using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToTikTokBlock.Input, + output_schema=PostToTikTokBlock.Output, + ) + + async def run( + self, input_data: "PostToTikTokBlock.Input", *, user_id: str, **kwargs + ) -> BlockOutput: + """Post to TikTok with TikTok-specific validation and options.""" + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate TikTok constraints + if len(input_data.post) > 2200: + yield "error", f"TikTok post text exceeds 2,200 character limit ({len(input_data.post)} characters)" + return + + if not input_data.media_urls: + yield "error", "TikTok requires at least one media URL (either 1 video or up to 35 images)" + return + + # Check for video vs image constraints + video_extensions = [".mp4", ".mov", ".avi", ".mkv", ".wmv", ".flv", ".webm"] + image_extensions = [".jpg", ".jpeg", ".webp"] + + has_video = input_data.is_video or any( + any(url.lower().endswith(ext) for ext in video_extensions) + for url in input_data.media_urls + ) + + has_images = any( + any(url.lower().endswith(ext) for ext in image_extensions) + for url in input_data.media_urls + ) + + if has_video and has_images: + yield "error", "TikTok does not support mixing video and images in the same post" + return + + if has_video and len(input_data.media_urls) > 1: + yield "error", "TikTok supports only 1 video per post" + return + + if has_images and len(input_data.media_urls) > 35: + yield "error", "TikTok supports a maximum of 35 images per post" + return + + # Validate image cover index + if has_images and input_data.image_cover_index >= len(input_data.media_urls): + yield "error", f"Image cover index {input_data.image_cover_index} is out of range (max: {len(input_data.media_urls) - 1})" + return + + # Check for PNG files (not supported) + has_png = any(url.lower().endswith(".png") for url in input_data.media_urls) + if has_png: + yield "error", "TikTok does not support PNG files. Please use JPG, JPEG, or WEBP for images." + return + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + # Build TikTok-specific options + tiktok_options = {} + + # Common options + if input_data.auto_add_music and has_images: + tiktok_options["autoAddMusic"] = True + + if input_data.disable_comments: + tiktok_options["disableComments"] = True + + if input_data.is_branded_content: + tiktok_options["isBrandedContent"] = True + + if input_data.is_brand_organic: + tiktok_options["isBrandOrganic"] = True + + # Video-specific options + if has_video: + if input_data.disable_duet: + tiktok_options["disableDuet"] = True + + if input_data.disable_stitch: + tiktok_options["disableStitch"] = True + + if input_data.is_ai_generated: + tiktok_options["isAIGenerated"] = True + + if input_data.thumbnail_offset > 0: + tiktok_options["thumbNailOffset"] = input_data.thumbnail_offset + + if input_data.draft: + tiktok_options["draft"] = True + + # Image-specific options + if has_images: + if input_data.image_cover_index > 0: + tiktok_options["imageCoverIndex"] = input_data.image_cover_index + + if input_data.title: + tiktok_options["title"] = input_data.title + + if input_data.visibility != TikTokVisibility.PUBLIC: + tiktok_options["visibility"] = input_data.visibility.value + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.TIKTOK], + media_urls=input_data.media_urls, + is_video=has_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + tiktok_options=tiktok_options if tiktok_options else None, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_x.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_x.py new file mode 100644 index 000000000000..bc23ac2c78b8 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_x.py @@ -0,0 +1,241 @@ +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import BaseAyrshareInput, create_ayrshare_client, get_profile_key + + +class PostToXBlock(Block): + """Block for posting to X / Twitter with Twitter-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for X / Twitter posts.""" + + # Override post field to include X-specific information + post: str = SchemaField( + description="The post text (max 280 chars, up to 25,000 for Premium users). Use @handle to mention users. Use \\n\\n for thread breaks.", + advanced=False, + ) + + # Override media_urls to include X-specific constraints + media_urls: list[str] = SchemaField( + description="Optional list of media URLs. X supports up to 4 images or videos per tweet. Auto-preview links unless media is included.", + default_factory=list, + advanced=False, + ) + + # X-specific options + reply_to_id: str | None = SchemaField( + description="ID of the tweet to reply to", + default=None, + advanced=True, + ) + quote_tweet_id: str | None = SchemaField( + description="ID of the tweet to quote (low-level Tweet ID)", + default=None, + advanced=True, + ) + poll_options: list[str] = SchemaField( + description="Poll options (2-4 choices)", + default_factory=list, + advanced=True, + ) + poll_duration: int = SchemaField( + description="Poll duration in minutes (1-10080)", + default=1440, + advanced=True, + ) + alt_text: list[str] = SchemaField( + description="Alt text for each image (max 1,000 chars each, not supported for videos)", + default_factory=list, + advanced=True, + ) + is_thread: bool = SchemaField( + description="Whether to automatically break post into thread based on line breaks", + default=False, + advanced=True, + ) + thread_number: bool = SchemaField( + description="Add thread numbers (1/n format) to each thread post", + default=False, + advanced=True, + ) + thread_media_urls: list[str] = SchemaField( + description="Media URLs for thread posts (one per thread, use 'null' to skip)", + default_factory=list, + advanced=True, + ) + long_post: bool = SchemaField( + description="Force long form post (requires Premium X account)", + default=False, + advanced=True, + ) + long_video: bool = SchemaField( + description="Enable long video upload (requires approval and Business/Enterprise plan)", + default=False, + advanced=True, + ) + subtitle_url: str = SchemaField( + description="URL to SRT subtitle file for videos (must be HTTPS and end in .srt)", + default="", + advanced=True, + ) + subtitle_language: str = SchemaField( + description="Language code for subtitles (default: 'en')", + default="en", + advanced=True, + ) + subtitle_name: str = SchemaField( + description="Name of caption track (max 150 chars, default: 'English')", + default="English", + advanced=True, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + id="9e8f844e-b4a5-4b25-80f2-9e1dd7d67625", + description="Post to X / Twitter using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToXBlock.Input, + output_schema=PostToXBlock.Output, + ) + + async def run( + self, + input_data: "PostToXBlock.Input", + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + """Post to X / Twitter with enhanced X-specific options.""" + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate X constraints + if not input_data.long_post and len(input_data.post) > 280: + yield "error", f"X post text exceeds 280 character limit ({len(input_data.post)} characters). Enable 'long_post' for Premium accounts." + return + + if input_data.long_post and len(input_data.post) > 25000: + yield "error", f"X long post text exceeds 25,000 character limit ({len(input_data.post)} characters)" + return + + if len(input_data.media_urls) > 4: + yield "error", "X supports a maximum of 4 images or videos per tweet" + return + + # Validate poll options + if input_data.poll_options: + if len(input_data.poll_options) < 2 or len(input_data.poll_options) > 4: + yield "error", "X polls require 2-4 options" + return + + if input_data.poll_duration < 1 or input_data.poll_duration > 10080: + yield "error", "X poll duration must be between 1 and 10,080 minutes (7 days)" + return + + # Validate alt text + if input_data.alt_text: + for i, alt in enumerate(input_data.alt_text): + if len(alt) > 1000: + yield "error", f"X alt text {i+1} exceeds 1,000 character limit ({len(alt)} characters)" + return + + # Validate subtitle settings + if input_data.subtitle_url: + if not input_data.subtitle_url.startswith( + "https://" + ) or not input_data.subtitle_url.endswith(".srt"): + yield "error", "Subtitle URL must start with https:// and end with .srt" + return + + if len(input_data.subtitle_name) > 150: + yield "error", f"Subtitle name exceeds 150 character limit ({len(input_data.subtitle_name)} characters)" + return + + # Convert datetime to ISO format if provided + iso_date = ( + input_data.schedule_date.isoformat() if input_data.schedule_date else None + ) + + # Build X-specific options + twitter_options = {} + + # Basic options + if input_data.reply_to_id: + twitter_options["replyToId"] = input_data.reply_to_id + + if input_data.quote_tweet_id: + twitter_options["quoteTweetId"] = input_data.quote_tweet_id + + if input_data.long_post: + twitter_options["longPost"] = True + + if input_data.long_video: + twitter_options["longVideo"] = True + + # Poll options + if input_data.poll_options: + twitter_options["poll"] = { + "duration": input_data.poll_duration, + "options": input_data.poll_options, + } + + # Alt text for images + if input_data.alt_text: + twitter_options["altText"] = input_data.alt_text + + # Thread options + if input_data.is_thread: + twitter_options["thread"] = True + + if input_data.thread_number: + twitter_options["threadNumber"] = True + + if input_data.thread_media_urls: + twitter_options["mediaUrls"] = input_data.thread_media_urls + + # Video subtitle options + if input_data.subtitle_url: + twitter_options["subTitleUrl"] = input_data.subtitle_url + twitter_options["subTitleLanguage"] = input_data.subtitle_language + twitter_options["subTitleName"] = input_data.subtitle_name + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.TWITTER], + media_urls=input_data.media_urls, + is_video=input_data.is_video, + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + twitter_options=twitter_options if twitter_options else None, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/backend/backend/blocks/ayrshare/post_to_youtube.py b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_youtube.py new file mode 100644 index 000000000000..fbcc9afce678 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/ayrshare/post_to_youtube.py @@ -0,0 +1,310 @@ +from enum import Enum +from typing import Any + +from backend.integrations.ayrshare import PostIds, PostResponse, SocialPlatform +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + SchemaField, +) + +from ._util import BaseAyrshareInput, create_ayrshare_client, get_profile_key + + +class YouTubeVisibility(str, Enum): + PRIVATE = "private" + PUBLIC = "public" + UNLISTED = "unlisted" + + +class PostToYouTubeBlock(Block): + """Block for posting to YouTube with YouTube-specific options.""" + + class Input(BaseAyrshareInput): + """Input schema for YouTube posts.""" + + # Override post field to include YouTube-specific information + post: str = SchemaField( + description="Video description (max 5,000 chars, empty string allowed). Cannot contain < or > characters.", + advanced=False, + ) + + # Override media_urls to include YouTube-specific constraints + media_urls: list[str] = SchemaField( + description="Required video URL. YouTube only supports 1 video per post.", + default_factory=list, + advanced=False, + ) + + # YouTube-specific required options + title: str = SchemaField( + description="Video title (max 100 chars, required). Cannot contain < or > characters.", + advanced=False, + ) + + # YouTube-specific optional options + visibility: YouTubeVisibility = SchemaField( + description="Video visibility: 'private' (default), 'public' , or 'unlisted'", + default=YouTubeVisibility.PRIVATE, + advanced=False, + ) + thumbnail: str | None = SchemaField( + description="Thumbnail URL (JPEG/PNG under 2MB, must end in .png/.jpg/.jpeg). Requires phone verification.", + default=None, + advanced=True, + ) + playlist_id: str | None = SchemaField( + description="Playlist ID to add video (user must own playlist)", + default=None, + advanced=True, + ) + tags: list[str] | None = SchemaField( + description="Video tags (min 2 chars each, max 500 chars total)", + default=None, + advanced=True, + ) + made_for_kids: bool | None = SchemaField( + description="Self-declared kids content", default=None, advanced=True + ) + is_shorts: bool | None = SchemaField( + description="Post as YouTube Short (max 3 minutes, adds #shorts)", + default=None, + advanced=True, + ) + notify_subscribers: bool | None = SchemaField( + description="Send notification to subscribers", default=None, advanced=True + ) + category_id: int | None = SchemaField( + description="Video category ID (e.g., 24 = Entertainment)", + default=None, + advanced=True, + ) + contains_synthetic_media: bool | None = SchemaField( + description="Disclose realistic AI/synthetic content", + default=None, + advanced=True, + ) + publish_at: str | None = SchemaField( + description="UTC publish time (YouTube controlled, format: 2022-10-08T21:18:36Z)", + default=None, + advanced=True, + ) + # YouTube targeting options (flattened from YouTubeTargeting object) + targeting_block_countries: list[str] | None = SchemaField( + description="Country codes to block from viewing (e.g., ['US', 'CA'])", + default=None, + advanced=True, + ) + targeting_allow_countries: list[str] | None = SchemaField( + description="Country codes to allow viewing (e.g., ['GB', 'AU'])", + default=None, + advanced=True, + ) + subtitle_url: str | None = SchemaField( + description="URL to SRT or SBV subtitle file (must be HTTPS and end in .srt/.sbv, under 100MB)", + default=None, + advanced=True, + ) + subtitle_language: str | None = SchemaField( + description="Language code for subtitles (default: 'en')", + default=None, + advanced=True, + ) + subtitle_name: str | None = SchemaField( + description="Name of caption track (max 150 chars, default: 'English')", + default=None, + advanced=True, + ) + + class Output(BlockSchema): + post_result: PostResponse = SchemaField(description="The result of the post") + post: PostIds = SchemaField(description="The result of the post") + + def __init__(self): + super().__init__( + id="0082d712-ff1b-4c3d-8a8d-6c7721883b83", + description="Post to YouTube using Ayrshare", + categories={BlockCategory.SOCIAL}, + block_type=BlockType.AYRSHARE, + input_schema=PostToYouTubeBlock.Input, + output_schema=PostToYouTubeBlock.Output, + ) + + async def run( + self, + input_data: "PostToYouTubeBlock.Input", + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + """Post to YouTube with YouTube-specific validation and options.""" + + profile_key = await get_profile_key(user_id) + if not profile_key: + yield "error", "Please link a social account via Ayrshare" + return + + client = create_ayrshare_client() + if not client: + yield "error", "Ayrshare integration is not configured. Please set up the AYRSHARE_API_KEY." + return + + # Validate YouTube constraints + if not input_data.title: + yield "error", "YouTube requires a video title" + return + + if len(input_data.title) > 100: + yield "error", f"YouTube title exceeds 100 character limit ({len(input_data.title)} characters)" + return + + if len(input_data.post) > 5000: + yield "error", f"YouTube description exceeds 5,000 character limit ({len(input_data.post)} characters)" + return + + # Check for forbidden characters + forbidden_chars = ["<", ">"] + for char in forbidden_chars: + if char in input_data.title: + yield "error", f"YouTube title cannot contain '{char}' character" + return + if char in input_data.post: + yield "error", f"YouTube description cannot contain '{char}' character" + return + + if not input_data.media_urls: + yield "error", "YouTube requires exactly one video URL" + return + + if len(input_data.media_urls) > 1: + yield "error", "YouTube supports only 1 video per post" + return + + # Validate visibility option + valid_visibility = ["private", "public", "unlisted"] + if input_data.visibility not in valid_visibility: + yield "error", f"YouTube visibility must be one of: {', '.join(valid_visibility)}" + return + + # Validate thumbnail URL format + if input_data.thumbnail: + valid_extensions = [".png", ".jpg", ".jpeg"] + if not any( + input_data.thumbnail.lower().endswith(ext) for ext in valid_extensions + ): + yield "error", "YouTube thumbnail must end in .png, .jpg, or .jpeg" + return + + # Validate tags + if input_data.tags: + total_tag_length = sum(len(tag) for tag in input_data.tags) + if total_tag_length > 500: + yield "error", f"YouTube tags total length exceeds 500 characters ({total_tag_length} characters)" + return + + for tag in input_data.tags: + if len(tag) < 2: + yield "error", f"YouTube tag '{tag}' is too short (minimum 2 characters)" + return + + # Validate subtitle URL + if input_data.subtitle_url: + if not input_data.subtitle_url.startswith("https://"): + yield "error", "YouTube subtitle URL must start with https://" + return + + valid_subtitle_extensions = [".srt", ".sbv"] + if not any( + input_data.subtitle_url.lower().endswith(ext) + for ext in valid_subtitle_extensions + ): + yield "error", "YouTube subtitle URL must end in .srt or .sbv" + return + + if input_data.subtitle_name and len(input_data.subtitle_name) > 150: + yield "error", f"YouTube subtitle name exceeds 150 character limit ({len(input_data.subtitle_name)} characters)" + return + + # Validate publish_at format if provided + if input_data.publish_at and input_data.schedule_date: + yield "error", "Cannot use both 'publish_at' and 'schedule_date'. Use 'publish_at' for YouTube-controlled publishing." + return + + # Convert datetime to ISO format if provided (only if not using publish_at) + iso_date = None + if not input_data.publish_at and input_data.schedule_date: + iso_date = input_data.schedule_date.isoformat() + + # Build YouTube-specific options + youtube_options: dict[str, Any] = {"title": input_data.title} + + # Basic options + if input_data.visibility != "private": + youtube_options["visibility"] = input_data.visibility + + if input_data.thumbnail: + youtube_options["thumbNail"] = input_data.thumbnail + + if input_data.playlist_id: + youtube_options["playListId"] = input_data.playlist_id + + if input_data.tags: + youtube_options["tags"] = input_data.tags + + if input_data.made_for_kids: + youtube_options["madeForKids"] = True + + if input_data.is_shorts: + youtube_options["shorts"] = True + + if not input_data.notify_subscribers: + youtube_options["notifySubscribers"] = False + + if input_data.category_id and input_data.category_id > 0: + youtube_options["categoryId"] = input_data.category_id + + if input_data.contains_synthetic_media: + youtube_options["containsSyntheticMedia"] = True + + if input_data.publish_at: + youtube_options["publishAt"] = input_data.publish_at + + # Country targeting (from flattened fields) + targeting_dict = {} + if input_data.targeting_block_countries: + targeting_dict["block"] = input_data.targeting_block_countries + if input_data.targeting_allow_countries: + targeting_dict["allow"] = input_data.targeting_allow_countries + + if targeting_dict: + youtube_options["targeting"] = targeting_dict + + # Subtitle options + if input_data.subtitle_url: + youtube_options["subTitleUrl"] = input_data.subtitle_url + youtube_options["subTitleLanguage"] = input_data.subtitle_language + youtube_options["subTitleName"] = input_data.subtitle_name + + response = await client.create_post( + post=input_data.post, + platforms=[SocialPlatform.YOUTUBE], + media_urls=input_data.media_urls, + is_video=True, # YouTube only supports videos + schedule_date=iso_date, + disable_comments=input_data.disable_comments, + shorten_links=input_data.shorten_links, + unsplash=input_data.unsplash, + requires_approval=input_data.requires_approval, + random_post=input_data.random_post, + random_media_url=input_data.random_media_url, + notes=input_data.notes, + youtube_options=youtube_options, + profile_key=profile_key.get_secret_value(), + ) + yield "post_result", response + if response.postIds: + for p in response.postIds: + yield "post", p diff --git a/autogpt_platform/market/market/__init__.py b/autogpt_platform/backend/backend/blocks/baas/__init__.py similarity index 100% rename from autogpt_platform/market/market/__init__.py rename to autogpt_platform/backend/backend/blocks/baas/__init__.py diff --git a/autogpt_platform/backend/backend/blocks/baas/_api.py b/autogpt_platform/backend/backend/blocks/baas/_api.py new file mode 100644 index 000000000000..6a53653aa3af --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/baas/_api.py @@ -0,0 +1,205 @@ +""" +Meeting BaaS API client module. +All API calls centralized for consistency and maintainability. +""" + +from typing import Any, Dict, List, Optional + +from backend.sdk import Requests + + +class MeetingBaasAPI: + """Client for Meeting BaaS API endpoints.""" + + BASE_URL = "https://api.meetingbaas.com" + + def __init__(self, api_key: str): + """Initialize API client with authentication key.""" + self.api_key = api_key + self.headers = {"x-meeting-baas-api-key": api_key} + self.requests = Requests() + + # Bot Management Endpoints + + async def join_meeting( + self, + bot_name: str, + meeting_url: str, + reserved: bool = False, + bot_image: Optional[str] = None, + entry_message: Optional[str] = None, + start_time: Optional[int] = None, + speech_to_text: Optional[Dict[str, Any]] = None, + webhook_url: Optional[str] = None, + automatic_leave: Optional[Dict[str, Any]] = None, + extra: Optional[Dict[str, Any]] = None, + recording_mode: str = "speaker_view", + streaming: Optional[Dict[str, Any]] = None, + deduplication_key: Optional[str] = None, + zoom_sdk_id: Optional[str] = None, + zoom_sdk_pwd: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Deploy a bot to join and record a meeting. + + POST /bots + """ + body = { + "bot_name": bot_name, + "meeting_url": meeting_url, + "reserved": reserved, + "recording_mode": recording_mode, + } + + # Add optional fields if provided + if bot_image is not None: + body["bot_image"] = bot_image + if entry_message is not None: + body["entry_message"] = entry_message + if start_time is not None: + body["start_time"] = start_time + if speech_to_text is not None: + body["speech_to_text"] = speech_to_text + if webhook_url is not None: + body["webhook_url"] = webhook_url + if automatic_leave is not None: + body["automatic_leave"] = automatic_leave + if extra is not None: + body["extra"] = extra + if streaming is not None: + body["streaming"] = streaming + if deduplication_key is not None: + body["deduplication_key"] = deduplication_key + if zoom_sdk_id is not None: + body["zoom_sdk_id"] = zoom_sdk_id + if zoom_sdk_pwd is not None: + body["zoom_sdk_pwd"] = zoom_sdk_pwd + + response = await self.requests.post( + f"{self.BASE_URL}/bots", + headers=self.headers, + json=body, + ) + return response.json() + + async def leave_meeting(self, bot_id: str) -> bool: + """ + Remove a bot from an ongoing meeting. + + DELETE /bots/{uuid} + """ + response = await self.requests.delete( + f"{self.BASE_URL}/bots/{bot_id}", + headers=self.headers, + ) + return response.status in [200, 204] + + async def retranscribe( + self, + bot_uuid: str, + speech_to_text: Optional[Dict[str, Any]] = None, + webhook_url: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Re-run transcription on a bot's audio. + + POST /bots/retranscribe + """ + body: Dict[str, Any] = {"bot_uuid": bot_uuid} + + if speech_to_text is not None: + body["speech_to_text"] = speech_to_text + if webhook_url is not None: + body["webhook_url"] = webhook_url + + response = await self.requests.post( + f"{self.BASE_URL}/bots/retranscribe", + headers=self.headers, + json=body, + ) + + if response.status == 202: + return {"accepted": True} + return response.json() + + # Data Retrieval Endpoints + + async def get_meeting_data( + self, bot_id: str, include_transcripts: bool = True + ) -> Dict[str, Any]: + """ + Retrieve meeting data including recording and transcripts. + + GET /bots/meeting_data + """ + params = { + "bot_id": bot_id, + "include_transcripts": str(include_transcripts).lower(), + } + + response = await self.requests.get( + f"{self.BASE_URL}/bots/meeting_data", + headers=self.headers, + params=params, + ) + return response.json() + + async def get_screenshots(self, bot_id: str) -> List[Dict[str, Any]]: + """ + Retrieve screenshots captured during a meeting. + + GET /bots/{uuid}/screenshots + """ + response = await self.requests.get( + f"{self.BASE_URL}/bots/{bot_id}/screenshots", + headers=self.headers, + ) + result = response.json() + # Ensure we return a list + if isinstance(result, list): + return result + return [] + + async def delete_data(self, bot_id: str) -> bool: + """ + Delete a bot's recorded data. + + POST /bots/{uuid}/delete_data + """ + response = await self.requests.post( + f"{self.BASE_URL}/bots/{bot_id}/delete_data", + headers=self.headers, + ) + return response.status == 200 + + async def list_bots_with_metadata( + self, + limit: Optional[int] = None, + offset: Optional[int] = None, + sort_by: Optional[str] = None, + sort_order: Optional[str] = None, + filter_by: Optional[Dict[str, Any]] = None, + ) -> Dict[str, Any]: + """ + List bots with metadata including IDs, names, and meeting details. + + GET /bots/bots_with_metadata + """ + params = {} + if limit is not None: + params["limit"] = limit + if offset is not None: + params["offset"] = offset + if sort_by is not None: + params["sort_by"] = sort_by + if sort_order is not None: + params["sort_order"] = sort_order + if filter_by is not None: + params.update(filter_by) + + response = await self.requests.get( + f"{self.BASE_URL}/bots/bots_with_metadata", + headers=self.headers, + params=params, + ) + return response.json() diff --git a/autogpt_platform/backend/backend/blocks/baas/_config.py b/autogpt_platform/backend/backend/blocks/baas/_config.py new file mode 100644 index 000000000000..c6ae3e68f585 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/baas/_config.py @@ -0,0 +1,13 @@ +""" +Shared configuration for all Meeting BaaS blocks using the SDK pattern. +""" + +from backend.sdk import BlockCostType, ProviderBuilder + +# Configure the Meeting BaaS provider with API key authentication +baas = ( + ProviderBuilder("baas") + .with_api_key("MEETING_BAAS_API_KEY", "Meeting BaaS API Key") + .with_base_cost(5, BlockCostType.RUN) # Higher cost for meeting recording service + .build() +) diff --git a/autogpt_platform/backend/backend/blocks/baas/bots.py b/autogpt_platform/backend/backend/blocks/baas/bots.py new file mode 100644 index 000000000000..da8400bb2c5e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/baas/bots.py @@ -0,0 +1,217 @@ +""" +Meeting BaaS bot (recording) blocks. +""" + +from typing import Optional + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, +) + +from ._api import MeetingBaasAPI +from ._config import baas + + +class BaasBotJoinMeetingBlock(Block): + """ + Deploy a bot immediately or at a scheduled start_time to join and record a meeting. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = baas.credentials_field( + description="Meeting BaaS API credentials" + ) + meeting_url: str = SchemaField( + description="The URL of the meeting the bot should join" + ) + bot_name: str = SchemaField( + description="Display name for the bot in the meeting" + ) + bot_image: str = SchemaField( + description="URL to an image for the bot's avatar (16:9 ratio recommended)", + default="", + ) + entry_message: str = SchemaField( + description="Chat message the bot will post upon entry", default="" + ) + reserved: bool = SchemaField( + description="Use a reserved bot slot (joins 4 min before meeting)", + default=False, + ) + start_time: Optional[int] = SchemaField( + description="Unix timestamp (ms) when bot should join", default=None + ) + webhook_url: str | None = SchemaField( + description="URL to receive webhook events for this bot", default=None + ) + timeouts: dict = SchemaField( + description="Automatic leave timeouts configuration", default={} + ) + extra: dict = SchemaField( + description="Custom metadata to attach to the bot", default={} + ) + + class Output(BlockSchema): + bot_id: str = SchemaField(description="UUID of the deployed bot") + join_response: dict = SchemaField( + description="Full response from join operation" + ) + + def __init__(self): + super().__init__( + id="377d1a6a-a99b-46cf-9af3-1d1b12758e04", + description="Deploy a bot to join and record a meeting", + categories={BlockCategory.COMMUNICATION}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + api_key = credentials.api_key.get_secret_value() + api = MeetingBaasAPI(api_key) + + # Call API with all parameters + data = await api.join_meeting( + bot_name=input_data.bot_name, + meeting_url=input_data.meeting_url, + reserved=input_data.reserved, + bot_image=input_data.bot_image if input_data.bot_image else None, + entry_message=( + input_data.entry_message if input_data.entry_message else None + ), + start_time=input_data.start_time, + speech_to_text={"provider": "Default"}, + webhook_url=input_data.webhook_url if input_data.webhook_url else None, + automatic_leave=input_data.timeouts if input_data.timeouts else None, + extra=input_data.extra if input_data.extra else None, + ) + + yield "bot_id", data.get("bot_id", "") + yield "join_response", data + + +class BaasBotLeaveMeetingBlock(Block): + """ + Force the bot to exit the call. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = baas.credentials_field( + description="Meeting BaaS API credentials" + ) + bot_id: str = SchemaField(description="UUID of the bot to remove from meeting") + + class Output(BlockSchema): + left: bool = SchemaField(description="Whether the bot successfully left") + + def __init__(self): + super().__init__( + id="bf77d128-8b25-4280-b5c7-2d553ba7e482", + description="Remove a bot from an ongoing meeting", + categories={BlockCategory.COMMUNICATION}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + api_key = credentials.api_key.get_secret_value() + api = MeetingBaasAPI(api_key) + + # Leave meeting + left = await api.leave_meeting(input_data.bot_id) + + yield "left", left + + +class BaasBotFetchMeetingDataBlock(Block): + """ + Pull MP4 URL, transcript & metadata for a completed meeting. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = baas.credentials_field( + description="Meeting BaaS API credentials" + ) + bot_id: str = SchemaField(description="UUID of the bot whose data to fetch") + include_transcripts: bool = SchemaField( + description="Include transcript data in response", default=True + ) + + class Output(BlockSchema): + mp4_url: str = SchemaField( + description="URL to download the meeting recording (time-limited)" + ) + transcript: list = SchemaField(description="Meeting transcript data") + metadata: dict = SchemaField(description="Meeting metadata and bot information") + + def __init__(self): + super().__init__( + id="ea7c1309-303c-4da1-893f-89c0e9d64e78", + description="Retrieve recorded meeting data", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + api_key = credentials.api_key.get_secret_value() + api = MeetingBaasAPI(api_key) + + # Fetch meeting data + data = await api.get_meeting_data( + bot_id=input_data.bot_id, + include_transcripts=input_data.include_transcripts, + ) + + yield "mp4_url", data.get("mp4", "") + yield "transcript", data.get("bot_data", {}).get("transcripts", []) + yield "metadata", data.get("bot_data", {}).get("bot", {}) + + +class BaasBotDeleteRecordingBlock(Block): + """ + Purge MP4 + transcript data for privacy or storage management. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = baas.credentials_field( + description="Meeting BaaS API credentials" + ) + bot_id: str = SchemaField(description="UUID of the bot whose data to delete") + + class Output(BlockSchema): + deleted: bool = SchemaField( + description="Whether the data was successfully deleted" + ) + + def __init__(self): + super().__init__( + id="bf8d1aa6-42d8-4944-b6bd-6bac554c0d3b", + description="Permanently delete a meeting's recorded data", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + api_key = credentials.api_key.get_secret_value() + api = MeetingBaasAPI(api_key) + + # Delete recording data + deleted = await api.delete_data(input_data.bot_id) + + yield "deleted", deleted diff --git a/autogpt_platform/backend/backend/blocks/basic.py b/autogpt_platform/backend/backend/blocks/basic.py index 58a3ba241079..ef251489c742 100644 --- a/autogpt_platform/backend/backend/blocks/basic.py +++ b/autogpt_platform/backend/backend/blocks/basic.py @@ -1,11 +1,53 @@ -from typing import Any, List +import enum +from typing import Any from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema, BlockType from backend.data.model import SchemaField -from backend.util.mock import MockObject -from backend.util.text import TextFormatter +from backend.util.file import store_media_file +from backend.util.type import MediaFileType, convert -formatter = TextFormatter() + +class FileStoreBlock(Block): + class Input(BlockSchema): + file_in: MediaFileType = SchemaField( + description="The file to store in the temporary directory, it can be a URL, data URI, or local path." + ) + base_64: bool = SchemaField( + description="Whether produce an output in base64 format (not recommended, you can pass the string path just fine accross blocks).", + default=False, + advanced=True, + title="Produce Base64 Output", + ) + + class Output(BlockSchema): + file_out: MediaFileType = SchemaField( + description="The relative path to the stored file in the temporary directory." + ) + + def __init__(self): + super().__init__( + id="cbb50872-625b-42f0-8203-a2ae78242d8a", + description="Stores the input file in the temporary directory.", + categories={BlockCategory.BASIC, BlockCategory.MULTIMEDIA}, + input_schema=FileStoreBlock.Input, + output_schema=FileStoreBlock.Output, + static_output=True, + ) + + async def run( + self, + input_data: Input, + *, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + yield "file_out", await store_media_file( + graph_exec_id=graph_exec_id, + file=input_data.file_in, + user_id=user_id, + return_content=input_data.base_64, + ) class StoreValueBlock(Block): @@ -47,15 +89,16 @@ def __init__(self): static_output=True, ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: yield "output", input_data.data or input_data.input class PrintToConsoleBlock(Block): class Input(BlockSchema): - text: str = SchemaField(description="The text to print to the console.") + text: Any = SchemaField(description="The data to print to the console.") class Output(BlockSchema): + output: Any = SchemaField(description="The data printed to the console.") status: str = SchemaField(description="The status of the print operation.") def __init__(self): @@ -66,407 +109,15 @@ def __init__(self): input_schema=PrintToConsoleBlock.Input, output_schema=PrintToConsoleBlock.Output, test_input={"text": "Hello, World!"}, - test_output=("status", "printed"), - ) - - def run(self, input_data: Input, **kwargs) -> BlockOutput: - print(">>>>> Print: ", input_data.text) - yield "status", "printed" - - -class FindInDictionaryBlock(Block): - class Input(BlockSchema): - input: Any = SchemaField(description="Dictionary to lookup from") - key: str | int = SchemaField(description="Key to lookup in the dictionary") - - class Output(BlockSchema): - output: Any = SchemaField(description="Value found for the given key") - missing: Any = SchemaField( - description="Value of the input that missing the key" - ) - - def __init__(self): - super().__init__( - id="0e50422c-6dee-4145-83d6-3a5a392f65de", - description="Lookup the given key in the input dictionary/object/list and return the value.", - input_schema=FindInDictionaryBlock.Input, - output_schema=FindInDictionaryBlock.Output, - test_input=[ - {"input": {"apple": 1, "banana": 2, "cherry": 3}, "key": "banana"}, - {"input": {"x": 10, "y": 20, "z": 30}, "key": "w"}, - {"input": [1, 2, 3], "key": 1}, - {"input": [1, 2, 3], "key": 3}, - {"input": MockObject(value="!!", key="key"), "key": "key"}, - {"input": [{"k1": "v1"}, {"k2": "v2"}, {"k1": "v3"}], "key": "k1"}, - ], - test_output=[ - ("output", 2), - ("missing", {"x": 10, "y": 20, "z": 30}), - ("output", 2), - ("missing", [1, 2, 3]), - ("output", "key"), - ("output", ["v1", "v3"]), - ], - categories={BlockCategory.BASIC}, - ) - - def run(self, input_data: Input, **kwargs) -> BlockOutput: - obj = input_data.input - key = input_data.key - - if isinstance(obj, dict) and key in obj: - yield "output", obj[key] - elif isinstance(obj, list) and isinstance(key, int) and 0 <= key < len(obj): - yield "output", obj[key] - elif isinstance(obj, list) and isinstance(key, str): - if len(obj) == 0: - yield "output", [] - elif isinstance(obj[0], dict) and key in obj[0]: - yield "output", [item[key] for item in obj if key in item] - else: - yield "output", [getattr(val, key) for val in obj if hasattr(val, key)] - elif isinstance(obj, object) and isinstance(key, str) and hasattr(obj, key): - yield "output", getattr(obj, key) - else: - yield "missing", input_data.input - - -class AgentInputBlock(Block): - """ - This block is used to provide input to the graph. - - It takes in a value, name, description, default values list and bool to limit selection to default values. - - It Outputs the value passed as input. - """ - - class Input(BlockSchema): - name: str = SchemaField(description="The name of the input.") - value: Any = SchemaField( - description="The value to be passed as input.", - default=None, - ) - title: str | None = SchemaField( - description="The title of the input.", default=None, advanced=True - ) - description: str | None = SchemaField( - description="The description of the input.", - default=None, - advanced=True, - ) - placeholder_values: List[Any] = SchemaField( - description="The placeholder values to be passed as input.", - default=[], - advanced=True, - ) - limit_to_placeholder_values: bool = SchemaField( - description="Whether to limit the selection to placeholder values.", - default=False, - advanced=True, - ) - advanced: bool = SchemaField( - description="Whether to show the input in the advanced section, if the field is not required.", - default=False, - advanced=True, - ) - secret: bool = SchemaField( - description="Whether the input should be treated as a secret.", - default=False, - advanced=True, - ) - - class Output(BlockSchema): - result: Any = SchemaField(description="The value passed as input.") - - def __init__(self): - super().__init__( - id="c0a8e994-ebf1-4a9c-a4d8-89d09c86741b", - description="This block is used to provide input to the graph.", - input_schema=AgentInputBlock.Input, - output_schema=AgentInputBlock.Output, - test_input=[ - { - "value": "Hello, World!", - "name": "input_1", - "description": "This is a test input.", - "placeholder_values": [], - "limit_to_placeholder_values": False, - }, - { - "value": "Hello, World!", - "name": "input_2", - "description": "This is a test input.", - "placeholder_values": ["Hello, World!"], - "limit_to_placeholder_values": True, - }, - ], test_output=[ - ("result", "Hello, World!"), - ("result", "Hello, World!"), - ], - categories={BlockCategory.INPUT, BlockCategory.BASIC}, - block_type=BlockType.INPUT, - static_output=True, - ) - - def run(self, input_data: Input, **kwargs) -> BlockOutput: - yield "result", input_data.value - - -class AgentOutputBlock(Block): - """ - Records the output of the graph for users to see. - - Behavior: - If `format` is provided and the `value` is of a type that can be formatted, - the block attempts to format the recorded_value using the `format`. - If formatting fails or no `format` is provided, the raw `value` is output. - """ - - class Input(BlockSchema): - value: Any = SchemaField( - description="The value to be recorded as output.", - default=None, - advanced=False, - ) - name: str = SchemaField(description="The name of the output.") - title: str | None = SchemaField( - description="The title of the output.", - default=None, - advanced=True, - ) - description: str | None = SchemaField( - description="The description of the output.", - default=None, - advanced=True, - ) - format: str = SchemaField( - description="The format string to be used to format the recorded_value.", - default="", - advanced=True, - ) - advanced: bool = SchemaField( - description="Whether to treat the output as advanced.", - default=False, - advanced=True, - ) - secret: bool = SchemaField( - description="Whether the output should be treated as a secret.", - default=False, - advanced=True, - ) - - class Output(BlockSchema): - output: Any = SchemaField(description="The value recorded as output.") - - def __init__(self): - super().__init__( - id="363ae599-353e-4804-937e-b2ee3cef3da4", - description="Stores the output of the graph for users to see.", - input_schema=AgentOutputBlock.Input, - output_schema=AgentOutputBlock.Output, - test_input=[ - { - "value": "Hello, World!", - "name": "output_1", - "description": "This is a test output.", - "format": "{{ output_1 }}!!", - }, - { - "value": "42", - "name": "output_2", - "description": "This is another test output.", - "format": "{{ output_2 }}", - }, - { - "value": MockObject(value="!!", key="key"), - "name": "output_3", - "description": "This is a test output with a mock object.", - "format": "{{ output_3 }}", - }, - ], - test_output=[ - ("output", "Hello, World!!!"), - ("output", "42"), - ("output", MockObject(value="!!", key="key")), - ], - categories={BlockCategory.OUTPUT, BlockCategory.BASIC}, - block_type=BlockType.OUTPUT, - static_output=True, - ) - - def run(self, input_data: Input, **kwargs) -> BlockOutput: - """ - Attempts to format the recorded_value using the fmt_string if provided. - If formatting fails or no fmt_string is given, returns the original recorded_value. - """ - if input_data.format: - try: - yield "output", formatter.format_string( - input_data.format, {input_data.name: input_data.value} - ) - except Exception as e: - yield "output", f"Error: {e}, {input_data.value}" - else: - yield "output", input_data.value - - -class AddToDictionaryBlock(Block): - class Input(BlockSchema): - dictionary: dict[Any, Any] = SchemaField( - default={}, - description="The dictionary to add the entry to. If not provided, a new dictionary will be created.", - ) - key: str = SchemaField( - default="", - description="The key for the new entry.", - placeholder="new_key", - advanced=False, - ) - value: Any = SchemaField( - default=None, - description="The value for the new entry.", - placeholder="new_value", - advanced=False, - ) - entries: dict[Any, Any] = SchemaField( - default={}, - description="The entries to add to the dictionary. This is the batch version of the `key` and `value` fields.", - advanced=True, - ) - - class Output(BlockSchema): - updated_dictionary: dict = SchemaField( - description="The dictionary with the new entry added." - ) - error: str = SchemaField(description="Error message if the operation failed.") - - def __init__(self): - super().__init__( - id="31d1064e-7446-4693-a7d4-65e5ca1180d1", - description="Adds a new key-value pair to a dictionary. If no dictionary is provided, a new one is created.", - categories={BlockCategory.BASIC}, - input_schema=AddToDictionaryBlock.Input, - output_schema=AddToDictionaryBlock.Output, - test_input=[ - { - "dictionary": {"existing_key": "existing_value"}, - "key": "new_key", - "value": "new_value", - }, - {"key": "first_key", "value": "first_value"}, - { - "dictionary": {"existing_key": "existing_value"}, - "entries": {"new_key": "new_value", "first_key": "first_value"}, - }, - ], - test_output=[ - ( - "updated_dictionary", - {"existing_key": "existing_value", "new_key": "new_value"}, - ), - ("updated_dictionary", {"first_key": "first_value"}), - ( - "updated_dictionary", - { - "existing_key": "existing_value", - "new_key": "new_value", - "first_key": "first_value", - }, - ), - ], - ) - - def run(self, input_data: Input, **kwargs) -> BlockOutput: - updated_dict = input_data.dictionary.copy() - - if input_data.value is not None and input_data.key: - updated_dict[input_data.key] = input_data.value - - for key, value in input_data.entries.items(): - updated_dict[key] = value - - yield "updated_dictionary", updated_dict - - -class AddToListBlock(Block): - class Input(BlockSchema): - list: List[Any] = SchemaField( - default=[], - advanced=False, - description="The list to add the entry to. If not provided, a new list will be created.", - ) - entry: Any = SchemaField( - description="The entry to add to the list. Can be of any type (string, int, dict, etc.).", - advanced=False, - default=None, - ) - entries: List[Any] = SchemaField( - default=[], - description="The entries to add to the list. This is the batch version of the `entry` field.", - advanced=True, - ) - position: int | None = SchemaField( - default=None, - description="The position to insert the new entry. If not provided, the entry will be appended to the end of the list.", - ) - - class Output(BlockSchema): - updated_list: List[Any] = SchemaField( - description="The list with the new entry added." - ) - error: str = SchemaField(description="Error message if the operation failed.") - - def __init__(self): - super().__init__( - id="aeb08fc1-2fc1-4141-bc8e-f758f183a822", - description="Adds a new entry to a list. The entry can be of any type. If no list is provided, a new one is created.", - categories={BlockCategory.BASIC}, - input_schema=AddToListBlock.Input, - output_schema=AddToListBlock.Output, - test_input=[ - { - "list": [1, "string", {"existing_key": "existing_value"}], - "entry": {"new_key": "new_value"}, - "position": 1, - }, - {"entry": "first_entry"}, - {"list": ["a", "b", "c"], "entry": "d"}, - { - "entry": "e", - "entries": ["f", "g"], - "list": ["a", "b"], - "position": 1, - }, - ], - test_output=[ - ( - "updated_list", - [ - 1, - {"new_key": "new_value"}, - "string", - {"existing_key": "existing_value"}, - ], - ), - ("updated_list", ["first_entry"]), - ("updated_list", ["a", "b", "c", "d"]), - ("updated_list", ["a", "f", "g", "e", "b"]), + ("output", "Hello, World!"), + ("status", "printed"), ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: - entries_added = input_data.entries.copy() - if input_data.entry: - entries_added.append(input_data.entry) - - updated_list = input_data.list.copy() - if (pos := input_data.position) is not None: - updated_list = updated_list[:pos] + entries_added + updated_list[pos:] - else: - updated_list += entries_added - - yield "updated_list", updated_list + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + yield "output", input_data.text + yield "status", "printed" class NoteBlock(Block): @@ -490,103 +141,78 @@ def __init__(self): block_type=BlockType.NOTE, ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: yield "output", input_data.text -class CreateDictionaryBlock(Block): +class TypeOptions(enum.Enum): + STRING = "string" + NUMBER = "number" + BOOLEAN = "boolean" + LIST = "list" + DICTIONARY = "dictionary" + + +class UniversalTypeConverterBlock(Block): class Input(BlockSchema): - values: dict[str, Any] = SchemaField( - description="Key-value pairs to create the dictionary with", - placeholder="e.g., {'name': 'Alice', 'age': 25}", + value: Any = SchemaField( + description="The value to convert to a universal type." ) + type: TypeOptions = SchemaField(description="The type to convert the value to.") class Output(BlockSchema): - dictionary: dict[str, Any] = SchemaField( - description="The created dictionary containing the specified key-value pairs" - ) - error: str = SchemaField( - description="Error message if dictionary creation failed" - ) + value: Any = SchemaField(description="The converted value.") + error: str = SchemaField(description="Error message if conversion failed.") def __init__(self): super().__init__( - id="b924ddf4-de4f-4b56-9a85-358930dcbc91", - description="Creates a dictionary with the specified key-value pairs. Use this when you know all the values you want to add upfront.", - categories={BlockCategory.DATA}, - input_schema=CreateDictionaryBlock.Input, - output_schema=CreateDictionaryBlock.Output, - test_input=[ - { - "values": {"name": "Alice", "age": 25, "city": "New York"}, - }, - { - "values": {"numbers": [1, 2, 3], "active": True, "score": 95.5}, - }, - ], - test_output=[ - ( - "dictionary", - {"name": "Alice", "age": 25, "city": "New York"}, - ), - ( - "dictionary", - {"numbers": [1, 2, 3], "active": True, "score": 95.5}, - ), - ], + id="95d1b990-ce13-4d88-9737-ba5c2070c97b", + description="This block is used to convert a value to a universal type.", + categories={BlockCategory.BASIC}, + input_schema=UniversalTypeConverterBlock.Input, + output_schema=UniversalTypeConverterBlock.Output, ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: try: - # The values are already validated by Pydantic schema - yield "dictionary", input_data.values + converted_value = convert( + input_data.value, + { + TypeOptions.STRING: str, + TypeOptions.NUMBER: float, + TypeOptions.BOOLEAN: bool, + TypeOptions.LIST: list, + TypeOptions.DICTIONARY: dict, + }[input_data.type], + ) + yield "value", converted_value except Exception as e: - yield "error", f"Failed to create dictionary: {str(e)}" + yield "error", f"Failed to convert value: {str(e)}" -class CreateListBlock(Block): +class ReverseListOrderBlock(Block): + """ + A block which takes in a list and returns it in the opposite order. + """ + class Input(BlockSchema): - values: List[Any] = SchemaField( - description="A list of values to be combined into a new list.", - placeholder="e.g., ['Alice', 25, True]", - ) + input_list: list[Any] = SchemaField(description="The list to reverse") class Output(BlockSchema): - list: List[Any] = SchemaField( - description="The created list containing the specified values." - ) - error: str = SchemaField(description="Error message if list creation failed.") + reversed_list: list[Any] = SchemaField(description="The list in reversed order") def __init__(self): super().__init__( - id="a912d5c7-6e00-4542-b2a9-8034136930e4", - description="Creates a list with the specified values. Use this when you know all the values you want to add upfront.", - categories={BlockCategory.DATA}, - input_schema=CreateListBlock.Input, - output_schema=CreateListBlock.Output, - test_input=[ - { - "values": ["Alice", 25, True], - }, - { - "values": [1, 2, 3, "four", {"key": "value"}], - }, - ], - test_output=[ - ( - "list", - ["Alice", 25, True], - ), - ( - "list", - [1, 2, 3, "four", {"key": "value"}], - ), - ], + id="422cb708-3109-4277-bfe3-bc2ae5812777", + description="Reverses the order of elements in a list", + categories={BlockCategory.BASIC}, + input_schema=ReverseListOrderBlock.Input, + output_schema=ReverseListOrderBlock.Output, + test_input={"input_list": [1, 2, 3, 4, 5]}, + test_output=[("reversed_list", [5, 4, 3, 2, 1])], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: - try: - # The values are already validated by Pydantic schema - yield "list", input_data.values - except Exception as e: - yield "error", f"Failed to create list: {str(e)}" + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + reversed_list = list(input_data.input_list) + reversed_list.reverse() + yield "reversed_list", reversed_list diff --git a/autogpt_platform/backend/backend/blocks/block.py b/autogpt_platform/backend/backend/blocks/block.py index 01e8af7238ea..e1745d30552e 100644 --- a/autogpt_platform/backend/backend/blocks/block.py +++ b/autogpt_platform/backend/backend/blocks/block.py @@ -38,7 +38,7 @@ def __init__(self): disabled=True, ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: code = input_data.code if search := re.search(r"class (\w+)\(Block\):", code): @@ -64,7 +64,7 @@ def run(self, input_data: Input, **kwargs) -> BlockOutput: from backend.util.test import execute_block_test - execute_block_test(block) + await execute_block_test(block) yield "success", "Block installed successfully." except Exception as e: os.remove(file_path) diff --git a/autogpt_platform/backend/backend/blocks/branching.py b/autogpt_platform/backend/backend/blocks/branching.py index daf967bc6a7d..fa66c2d30d5b 100644 --- a/autogpt_platform/backend/backend/blocks/branching.py +++ b/autogpt_platform/backend/backend/blocks/branching.py @@ -3,6 +3,7 @@ from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField +from backend.util.type import convert class ComparisonOperator(Enum): @@ -70,7 +71,7 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: operator = input_data.operator value1 = input_data.value1 @@ -107,3 +108,99 @@ def run(self, input_data: Input, **kwargs) -> BlockOutput: yield "yes_output", yes_value else: yield "no_output", no_value + + +class IfInputMatchesBlock(Block): + class Input(BlockSchema): + input: Any = SchemaField( + description="The input to match against", + placeholder="For example: 10 or 'hello' or True", + ) + value: Any = SchemaField( + description="The value to output if the input matches", + placeholder="For example: 'Greater' or 20 or False", + ) + yes_value: Any = SchemaField( + description="The value to output if the input matches", + placeholder="For example: 'Greater' or 20 or False", + default=None, + ) + no_value: Any = SchemaField( + description="The value to output if the input does not match", + placeholder="For example: 'Greater' or 20 or False", + default=None, + ) + + class Output(BlockSchema): + result: bool = SchemaField( + description="The result of the condition evaluation (True or False)" + ) + yes_output: Any = SchemaField( + description="The output value if the condition is true" + ) + no_output: Any = SchemaField( + description="The output value if the condition is false" + ) + + def __init__(self): + super().__init__( + id="6dbbc4b3-ca6c-42b6-b508-da52d23e13f2", + input_schema=IfInputMatchesBlock.Input, + output_schema=IfInputMatchesBlock.Output, + description="Handles conditional logic based on comparison operators", + categories={BlockCategory.LOGIC}, + test_input=[ + { + "input": 10, + "value": 10, + "yes_value": "Greater", + "no_value": "Not greater", + }, + { + "input": 10, + "value": 20, + "yes_value": "Greater", + "no_value": "Not greater", + }, + { + "input": 10, + "value": "None", + "yes_value": "Yes", + "no_value": "No", + }, + ], + test_output=[ + ("result", True), + ("yes_output", "Greater"), + ("result", False), + ("no_output", "Not greater"), + ("result", False), + ("no_output", "No"), + # ("result", True), + # ("yes_output", "Yes"), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + + # If input_data.value is not matching input_data.input, convert value to type of input + if ( + input_data.input != input_data.value + and input_data.input is not input_data.value + ): + try: + # Only attempt conversion if input is not None and value is not None + if input_data.input is not None and input_data.value is not None: + input_type = type(input_data.input) + # Avoid converting if input_type is Any or object + if input_type not in (Any, object): + input_data.value = convert(input_data.value, input_type) + except Exception: + pass # If conversion fails, just leave value as is + + if input_data.input == input_data.value: + yield "result", True + yield "yes_output", input_data.yes_value + else: + yield "result", False + yield "no_output", input_data.no_value diff --git a/autogpt_platform/backend/backend/blocks/code_executor.py b/autogpt_platform/backend/backend/blocks/code_executor.py index 4c92f6fb4242..e25231e90e4d 100644 --- a/autogpt_platform/backend/backend/blocks/code_executor.py +++ b/autogpt_platform/backend/backend/blocks/code_executor.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Literal -from e2b_code_interpreter import Sandbox +from e2b_code_interpreter import AsyncSandbox from pydantic import SecretStr from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema @@ -55,7 +55,7 @@ class Input(BlockSchema): "These commands are executed with `sh`, in the foreground." ), placeholder="pip install cowsay", - default=[], + default_factory=list, advanced=False, ) @@ -123,7 +123,7 @@ def __init__(self): }, ) - def execute_code( + async def execute_code( self, code: str, language: ProgrammingLanguage, @@ -135,21 +135,21 @@ def execute_code( try: sandbox = None if template_id: - sandbox = Sandbox( + sandbox = await AsyncSandbox.create( template=template_id, api_key=api_key, timeout=timeout ) else: - sandbox = Sandbox(api_key=api_key, timeout=timeout) + sandbox = await AsyncSandbox.create(api_key=api_key, timeout=timeout) if not sandbox: raise Exception("Sandbox not created") # Running setup commands for cmd in setup_commands: - sandbox.commands.run(cmd) + await sandbox.commands.run(cmd) # Executing the code - execution = sandbox.run_code( + execution = await sandbox.run_code( code, language=language.value, on_error=lambda e: sandbox.kill(), # Kill the sandbox if there is an error @@ -167,11 +167,11 @@ def execute_code( except Exception as e: raise e - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: try: - response, stdout_logs, stderr_logs = self.execute_code( + response, stdout_logs, stderr_logs = await self.execute_code( input_data.code, input_data.language, input_data.setup_commands, @@ -188,3 +188,270 @@ def run( yield "stderr_logs", stderr_logs except Exception as e: yield "error", str(e) + + +class InstantiationBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput[ + Literal[ProviderName.E2B], Literal["api_key"] + ] = CredentialsField( + description="Enter your api key for the E2B Sandbox. You can get it in here - https://e2b.dev/docs", + ) + + # Todo : Option to run commond in background + setup_commands: list[str] = SchemaField( + description=( + "Shell commands to set up the sandbox before running the code. " + "You can use `curl` or `git` to install your desired Debian based " + "package manager. `pip` and `npm` are pre-installed.\n\n" + "These commands are executed with `sh`, in the foreground." + ), + placeholder="pip install cowsay", + default_factory=list, + advanced=False, + ) + + setup_code: str = SchemaField( + description="Code to execute in the sandbox", + placeholder="print('Hello, World!')", + default="", + advanced=False, + ) + + language: ProgrammingLanguage = SchemaField( + description="Programming language to execute", + default=ProgrammingLanguage.PYTHON, + advanced=False, + ) + + timeout: int = SchemaField( + description="Execution timeout in seconds", default=300 + ) + + template_id: str = SchemaField( + description=( + "You can use an E2B sandbox template by entering its ID here. " + "Check out the E2B docs for more details: " + "[E2B - Sandbox template](https://e2b.dev/docs/sandbox-template)" + ), + default="", + advanced=True, + ) + + class Output(BlockSchema): + sandbox_id: str = SchemaField(description="ID of the sandbox instance") + response: str = SchemaField(description="Response from code execution") + stdout_logs: str = SchemaField( + description="Standard output logs from execution" + ) + stderr_logs: str = SchemaField(description="Standard error logs from execution") + error: str = SchemaField(description="Error message if execution failed") + + def __init__(self): + super().__init__( + id="ff0861c9-1726-4aec-9e5b-bf53f3622112", + description="Instantiate an isolated sandbox environment with internet access where to execute code in.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=InstantiationBlock.Input, + output_schema=InstantiationBlock.Output, + test_credentials=TEST_CREDENTIALS, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "setup_code": "print('Hello World')", + "language": ProgrammingLanguage.PYTHON.value, + "setup_commands": [], + "timeout": 300, + "template_id": "", + }, + test_output=[ + ("sandbox_id", str), + ("response", "Hello World"), + ("stdout_logs", "Hello World\n"), + ], + test_mock={ + "execute_code": lambda setup_code, language, setup_commands, timeout, api_key, template_id: ( + "sandbox_id", + "Hello World", + "Hello World\n", + "", + ), + }, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + sandbox_id, response, stdout_logs, stderr_logs = await self.execute_code( + input_data.setup_code, + input_data.language, + input_data.setup_commands, + input_data.timeout, + credentials.api_key.get_secret_value(), + input_data.template_id, + ) + if sandbox_id: + yield "sandbox_id", sandbox_id + else: + yield "error", "Sandbox ID not found" + if response: + yield "response", response + if stdout_logs: + yield "stdout_logs", stdout_logs + if stderr_logs: + yield "stderr_logs", stderr_logs + except Exception as e: + yield "error", str(e) + + async def execute_code( + self, + code: str, + language: ProgrammingLanguage, + setup_commands: list[str], + timeout: int, + api_key: str, + template_id: str, + ): + try: + sandbox = None + if template_id: + sandbox = await AsyncSandbox.create( + template=template_id, api_key=api_key, timeout=timeout + ) + else: + sandbox = await AsyncSandbox.create(api_key=api_key, timeout=timeout) + + if not sandbox: + raise Exception("Sandbox not created") + + # Running setup commands + for cmd in setup_commands: + await sandbox.commands.run(cmd) + + # Executing the code + execution = await sandbox.run_code( + code, + language=language.value, + on_error=lambda e: sandbox.kill(), # Kill the sandbox if there is an error + ) + + if execution.error: + raise Exception(execution.error) + + response = execution.text + stdout_logs = "".join(execution.logs.stdout) + stderr_logs = "".join(execution.logs.stderr) + + return sandbox.sandbox_id, response, stdout_logs, stderr_logs + + except Exception as e: + raise e + + +class StepExecutionBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput[ + Literal[ProviderName.E2B], Literal["api_key"] + ] = CredentialsField( + description="Enter your api key for the E2B Sandbox. You can get it in here - https://e2b.dev/docs", + ) + + sandbox_id: str = SchemaField( + description="ID of the sandbox instance to execute the code in", + advanced=False, + ) + + step_code: str = SchemaField( + description="Code to execute in the sandbox", + placeholder="print('Hello, World!')", + default="", + advanced=False, + ) + + language: ProgrammingLanguage = SchemaField( + description="Programming language to execute", + default=ProgrammingLanguage.PYTHON, + advanced=False, + ) + + class Output(BlockSchema): + response: str = SchemaField(description="Response from code execution") + stdout_logs: str = SchemaField( + description="Standard output logs from execution" + ) + stderr_logs: str = SchemaField(description="Standard error logs from execution") + error: str = SchemaField(description="Error message if execution failed") + + def __init__(self): + super().__init__( + id="82b59b8e-ea10-4d57-9161-8b169b0adba6", + description="Execute code in a previously instantiated sandbox environment.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=StepExecutionBlock.Input, + output_schema=StepExecutionBlock.Output, + test_credentials=TEST_CREDENTIALS, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "sandbox_id": "sandbox_id", + "step_code": "print('Hello World')", + "language": ProgrammingLanguage.PYTHON.value, + }, + test_output=[ + ("response", "Hello World"), + ("stdout_logs", "Hello World\n"), + ], + test_mock={ + "execute_step_code": lambda sandbox_id, step_code, language, api_key: ( + "Hello World", + "Hello World\n", + "", + ), + }, + ) + + async def execute_step_code( + self, + sandbox_id: str, + code: str, + language: ProgrammingLanguage, + api_key: str, + ): + try: + sandbox = await AsyncSandbox.connect(sandbox_id=sandbox_id, api_key=api_key) + if not sandbox: + raise Exception("Sandbox not found") + + # Executing the code + execution = await sandbox.run_code(code, language=language.value) + + if execution.error: + raise Exception(execution.error) + + response = execution.text + stdout_logs = "".join(execution.logs.stdout) + stderr_logs = "".join(execution.logs.stderr) + + return response, stdout_logs, stderr_logs + + except Exception as e: + raise e + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + response, stdout_logs, stderr_logs = await self.execute_step_code( + input_data.sandbox_id, + input_data.step_code, + input_data.language, + credentials.api_key.get_secret_value(), + ) + + if response: + yield "response", response + if stdout_logs: + yield "stdout_logs", stdout_logs + if stderr_logs: + yield "stderr_logs", stderr_logs + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/code_extraction_block.py b/autogpt_platform/backend/backend/blocks/code_extraction_block.py index ab1e35aa5dca..33bf225bfdac 100644 --- a/autogpt_platform/backend/backend/blocks/code_extraction_block.py +++ b/autogpt_platform/backend/backend/blocks/code_extraction_block.py @@ -49,7 +49,7 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: # List of supported programming languages with mapped aliases language_aliases = { "html": ["html", "htm"], diff --git a/autogpt_platform/backend/backend/blocks/compass/triggers.py b/autogpt_platform/backend/backend/blocks/compass/triggers.py index c17becd9acbd..6eac52ce536d 100644 --- a/autogpt_platform/backend/backend/blocks/compass/triggers.py +++ b/autogpt_platform/backend/backend/blocks/compass/triggers.py @@ -8,6 +8,7 @@ BlockSchema, ) from backend.data.model import SchemaField +from backend.integrations.providers import ProviderName from backend.integrations.webhooks.compass import CompassWebhookType @@ -42,7 +43,7 @@ def __init__(self): input_schema=CompassAITriggerBlock.Input, output_schema=CompassAITriggerBlock.Output, webhook_config=BlockManualWebhookConfig( - provider="compass", + provider=ProviderName.COMPASS, webhook_type=CompassWebhookType.TRANSCRIPTION, ), test_input=[ @@ -55,5 +56,5 @@ def __init__(self): # ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: yield "transcription", input_data.payload.transcription diff --git a/autogpt_platform/backend/backend/blocks/count_words_and_char_block.py b/autogpt_platform/backend/backend/blocks/count_words_and_char_block.py index 13f9e3977941..ddbcf07876cb 100644 --- a/autogpt_platform/backend/backend/blocks/count_words_and_char_block.py +++ b/autogpt_platform/backend/backend/blocks/count_words_and_char_block.py @@ -30,7 +30,7 @@ def __init__(self): test_output=[("word_count", 4), ("character_count", 19)], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: try: text = input_data.text word_count = len(text.split()) diff --git a/autogpt_platform/backend/backend/blocks/csv.py b/autogpt_platform/backend/backend/blocks/csv.py deleted file mode 100644 index e78c8994737a..000000000000 --- a/autogpt_platform/backend/backend/blocks/csv.py +++ /dev/null @@ -1,109 +0,0 @@ -from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema -from backend.data.model import ContributorDetails, SchemaField - - -class ReadCsvBlock(Block): - class Input(BlockSchema): - contents: str = SchemaField( - description="The contents of the CSV file to read", - placeholder="a, b, c\n1,2,3\n4,5,6", - ) - delimiter: str = SchemaField( - description="The delimiter used in the CSV file", - default=",", - ) - quotechar: str = SchemaField( - description="The character used to quote fields", - default='"', - ) - escapechar: str = SchemaField( - description="The character used to escape the delimiter", - default="\\", - ) - has_header: bool = SchemaField( - description="Whether the CSV file has a header row", - default=True, - ) - skip_rows: int = SchemaField( - description="The number of rows to skip from the start of the file", - default=0, - ) - strip: bool = SchemaField( - description="Whether to strip whitespace from the values", - default=True, - ) - skip_columns: list[str] = SchemaField( - description="The columns to skip from the start of the row", - default=[], - ) - - class Output(BlockSchema): - row: dict[str, str] = SchemaField( - description="The data produced from each row in the CSV file" - ) - all_data: list[dict[str, str]] = SchemaField( - description="All the data in the CSV file as a list of rows" - ) - - def __init__(self): - super().__init__( - id="acf7625e-d2cb-4941-bfeb-2819fc6fc015", - input_schema=ReadCsvBlock.Input, - output_schema=ReadCsvBlock.Output, - description="Reads a CSV file and outputs the data as a list of dictionaries and individual rows via rows.", - contributors=[ContributorDetails(name="Nicholas Tindle")], - categories={BlockCategory.TEXT, BlockCategory.DATA}, - test_input={ - "contents": "a, b, c\n1,2,3\n4,5,6", - }, - test_output=[ - ("row", {"a": "1", "b": "2", "c": "3"}), - ("row", {"a": "4", "b": "5", "c": "6"}), - ( - "all_data", - [ - {"a": "1", "b": "2", "c": "3"}, - {"a": "4", "b": "5", "c": "6"}, - ], - ), - ], - ) - - def run(self, input_data: Input, **kwargs) -> BlockOutput: - import csv - from io import StringIO - - csv_file = StringIO(input_data.contents) - reader = csv.reader( - csv_file, - delimiter=input_data.delimiter, - quotechar=input_data.quotechar, - escapechar=input_data.escapechar, - ) - - header = None - if input_data.has_header: - header = next(reader) - if input_data.strip: - header = [h.strip() for h in header] - - for _ in range(input_data.skip_rows): - next(reader) - - def process_row(row): - data = {} - for i, value in enumerate(row): - if i not in input_data.skip_columns: - if input_data.has_header and header: - data[header[i]] = value.strip() if input_data.strip else value - else: - data[str(i)] = value.strip() if input_data.strip else value - return data - - all_data = [] - for row in reader: - processed_row = process_row(row) - all_data.append(processed_row) - yield "row", processed_row - - yield "all_data", all_data diff --git a/autogpt_platform/backend/backend/blocks/data_manipulation.py b/autogpt_platform/backend/backend/blocks/data_manipulation.py new file mode 100644 index 000000000000..4b65f77d339e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/data_manipulation.py @@ -0,0 +1,683 @@ +from typing import Any, List + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField +from backend.util.json import loads +from backend.util.mock import MockObject +from backend.util.prompt import estimate_token_count_str + +# ============================================================================= +# Dictionary Manipulation Blocks +# ============================================================================= + + +class CreateDictionaryBlock(Block): + class Input(BlockSchema): + values: dict[str, Any] = SchemaField( + description="Key-value pairs to create the dictionary with", + placeholder="e.g., {'name': 'Alice', 'age': 25}", + ) + + class Output(BlockSchema): + dictionary: dict[str, Any] = SchemaField( + description="The created dictionary containing the specified key-value pairs" + ) + error: str = SchemaField( + description="Error message if dictionary creation failed" + ) + + def __init__(self): + super().__init__( + id="b924ddf4-de4f-4b56-9a85-358930dcbc91", + description="Creates a dictionary with the specified key-value pairs. Use this when you know all the values you want to add upfront.", + categories={BlockCategory.DATA}, + input_schema=CreateDictionaryBlock.Input, + output_schema=CreateDictionaryBlock.Output, + test_input=[ + { + "values": {"name": "Alice", "age": 25, "city": "New York"}, + }, + { + "values": {"numbers": [1, 2, 3], "active": True, "score": 95.5}, + }, + ], + test_output=[ + ( + "dictionary", + {"name": "Alice", "age": 25, "city": "New York"}, + ), + ( + "dictionary", + {"numbers": [1, 2, 3], "active": True, "score": 95.5}, + ), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + try: + # The values are already validated by Pydantic schema + yield "dictionary", input_data.values + except Exception as e: + yield "error", f"Failed to create dictionary: {str(e)}" + + +class AddToDictionaryBlock(Block): + class Input(BlockSchema): + dictionary: dict[Any, Any] = SchemaField( + default_factory=dict, + description="The dictionary to add the entry to. If not provided, a new dictionary will be created.", + ) + key: str = SchemaField( + default="", + description="The key for the new entry.", + placeholder="new_key", + advanced=False, + ) + value: Any = SchemaField( + default=None, + description="The value for the new entry.", + placeholder="new_value", + advanced=False, + ) + entries: dict[Any, Any] = SchemaField( + default_factory=dict, + description="The entries to add to the dictionary. This is the batch version of the `key` and `value` fields.", + advanced=True, + ) + + class Output(BlockSchema): + updated_dictionary: dict = SchemaField( + description="The dictionary with the new entry added." + ) + error: str = SchemaField(description="Error message if the operation failed.") + + def __init__(self): + super().__init__( + id="31d1064e-7446-4693-a7d4-65e5ca1180d1", + description="Adds a new key-value pair to a dictionary. If no dictionary is provided, a new one is created.", + categories={BlockCategory.BASIC}, + input_schema=AddToDictionaryBlock.Input, + output_schema=AddToDictionaryBlock.Output, + test_input=[ + { + "dictionary": {"existing_key": "existing_value"}, + "key": "new_key", + "value": "new_value", + }, + {"key": "first_key", "value": "first_value"}, + { + "dictionary": {"existing_key": "existing_value"}, + "entries": {"new_key": "new_value", "first_key": "first_value"}, + }, + ], + test_output=[ + ( + "updated_dictionary", + {"existing_key": "existing_value", "new_key": "new_value"}, + ), + ("updated_dictionary", {"first_key": "first_value"}), + ( + "updated_dictionary", + { + "existing_key": "existing_value", + "new_key": "new_value", + "first_key": "first_value", + }, + ), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + updated_dict = input_data.dictionary.copy() + + if input_data.value is not None and input_data.key: + updated_dict[input_data.key] = input_data.value + + for key, value in input_data.entries.items(): + updated_dict[key] = value + + yield "updated_dictionary", updated_dict + + +class FindInDictionaryBlock(Block): + class Input(BlockSchema): + input: Any = SchemaField(description="Dictionary to lookup from") + key: str | int = SchemaField(description="Key to lookup in the dictionary") + + class Output(BlockSchema): + output: Any = SchemaField(description="Value found for the given key") + missing: Any = SchemaField( + description="Value of the input that missing the key" + ) + + def __init__(self): + super().__init__( + id="0e50422c-6dee-4145-83d6-3a5a392f65de", + description="Lookup the given key in the input dictionary/object/list and return the value.", + input_schema=FindInDictionaryBlock.Input, + output_schema=FindInDictionaryBlock.Output, + test_input=[ + {"input": {"apple": 1, "banana": 2, "cherry": 3}, "key": "banana"}, + {"input": {"x": 10, "y": 20, "z": 30}, "key": "w"}, + {"input": [1, 2, 3], "key": 1}, + {"input": [1, 2, 3], "key": 3}, + {"input": MockObject(value="!!", key="key"), "key": "key"}, + {"input": [{"k1": "v1"}, {"k2": "v2"}, {"k1": "v3"}], "key": "k1"}, + ], + test_output=[ + ("output", 2), + ("missing", {"x": 10, "y": 20, "z": 30}), + ("output", 2), + ("missing", [1, 2, 3]), + ("output", "key"), + ("output", ["v1", "v3"]), + ], + categories={BlockCategory.BASIC}, + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + obj = input_data.input + key = input_data.key + + if isinstance(obj, str): + obj = loads(obj) + + if isinstance(obj, dict) and key in obj: + yield "output", obj[key] + elif isinstance(obj, list) and isinstance(key, int) and 0 <= key < len(obj): + yield "output", obj[key] + elif isinstance(obj, list) and isinstance(key, str): + if len(obj) == 0: + yield "output", [] + elif isinstance(obj[0], dict) and key in obj[0]: + yield "output", [item[key] for item in obj if key in item] + else: + yield "output", [getattr(val, key) for val in obj if hasattr(val, key)] + elif isinstance(obj, object) and isinstance(key, str) and hasattr(obj, key): + yield "output", getattr(obj, key) + else: + yield "missing", input_data.input + + +class RemoveFromDictionaryBlock(Block): + class Input(BlockSchema): + dictionary: dict[Any, Any] = SchemaField( + description="The dictionary to modify." + ) + key: str | int = SchemaField(description="Key to remove from the dictionary.") + return_value: bool = SchemaField( + default=False, description="Whether to return the removed value." + ) + + class Output(BlockSchema): + updated_dictionary: dict[Any, Any] = SchemaField( + description="The dictionary after removal." + ) + removed_value: Any = SchemaField(description="The removed value if requested.") + error: str = SchemaField(description="Error message if the operation failed.") + + def __init__(self): + super().__init__( + id="46afe2ea-c613-43f8-95ff-6692c3ef6876", + description="Removes a key-value pair from a dictionary.", + categories={BlockCategory.BASIC}, + input_schema=RemoveFromDictionaryBlock.Input, + output_schema=RemoveFromDictionaryBlock.Output, + test_input=[ + { + "dictionary": {"a": 1, "b": 2, "c": 3}, + "key": "b", + "return_value": True, + }, + {"dictionary": {"x": "hello", "y": "world"}, "key": "x"}, + ], + test_output=[ + ("updated_dictionary", {"a": 1, "c": 3}), + ("removed_value", 2), + ("updated_dictionary", {"y": "world"}), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + updated_dict = input_data.dictionary.copy() + try: + removed_value = updated_dict.pop(input_data.key) + yield "updated_dictionary", updated_dict + if input_data.return_value: + yield "removed_value", removed_value + except KeyError: + yield "error", f"Key '{input_data.key}' not found in dictionary" + + +class ReplaceDictionaryValueBlock(Block): + class Input(BlockSchema): + dictionary: dict[Any, Any] = SchemaField( + description="The dictionary to modify." + ) + key: str | int = SchemaField(description="Key to replace the value for.") + value: Any = SchemaField(description="The new value for the given key.") + + class Output(BlockSchema): + updated_dictionary: dict[Any, Any] = SchemaField( + description="The dictionary after replacement." + ) + old_value: Any = SchemaField(description="The value that was replaced.") + error: str = SchemaField(description="Error message if the operation failed.") + + def __init__(self): + super().__init__( + id="27e31876-18b6-44f3-ab97-f6226d8b3889", + description="Replaces the value for a specified key in a dictionary.", + categories={BlockCategory.BASIC}, + input_schema=ReplaceDictionaryValueBlock.Input, + output_schema=ReplaceDictionaryValueBlock.Output, + test_input=[ + {"dictionary": {"a": 1, "b": 2, "c": 3}, "key": "b", "value": 99}, + { + "dictionary": {"x": "hello", "y": "world"}, + "key": "y", + "value": "universe", + }, + ], + test_output=[ + ("updated_dictionary", {"a": 1, "b": 99, "c": 3}), + ("old_value", 2), + ("updated_dictionary", {"x": "hello", "y": "universe"}), + ("old_value", "world"), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + updated_dict = input_data.dictionary.copy() + try: + old_value = updated_dict[input_data.key] + updated_dict[input_data.key] = input_data.value + yield "updated_dictionary", updated_dict + yield "old_value", old_value + except KeyError: + yield "error", f"Key '{input_data.key}' not found in dictionary" + + +class DictionaryIsEmptyBlock(Block): + class Input(BlockSchema): + dictionary: dict[Any, Any] = SchemaField(description="The dictionary to check.") + + class Output(BlockSchema): + is_empty: bool = SchemaField(description="True if the dictionary is empty.") + + def __init__(self): + super().__init__( + id="a3cf3f64-6bb9-4cc6-9900-608a0b3359b0", + description="Checks if a dictionary is empty.", + categories={BlockCategory.BASIC}, + input_schema=DictionaryIsEmptyBlock.Input, + output_schema=DictionaryIsEmptyBlock.Output, + test_input=[{"dictionary": {}}, {"dictionary": {"a": 1}}], + test_output=[("is_empty", True), ("is_empty", False)], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + yield "is_empty", len(input_data.dictionary) == 0 + + +# ============================================================================= +# List Manipulation Blocks +# ============================================================================= + + +class CreateListBlock(Block): + class Input(BlockSchema): + values: List[Any] = SchemaField( + description="A list of values to be combined into a new list.", + placeholder="e.g., ['Alice', 25, True]", + ) + max_size: int | None = SchemaField( + default=None, + description="Maximum size of the list. If provided, the list will be yielded in chunks of this size.", + advanced=True, + ) + max_tokens: int | None = SchemaField( + default=None, + description="Maximum tokens for the list. If provided, the list will be yielded in chunks that fit within this token limit.", + advanced=True, + ) + + class Output(BlockSchema): + list: List[Any] = SchemaField( + description="The created list containing the specified values." + ) + error: str = SchemaField(description="Error message if list creation failed.") + + def __init__(self): + super().__init__( + id="a912d5c7-6e00-4542-b2a9-8034136930e4", + description="Creates a list with the specified values. Use this when you know all the values you want to add upfront. This block can also yield the list in batches based on a maximum size or token limit.", + categories={BlockCategory.DATA}, + input_schema=CreateListBlock.Input, + output_schema=CreateListBlock.Output, + test_input=[ + { + "values": ["Alice", 25, True], + }, + { + "values": [1, 2, 3, "four", {"key": "value"}], + }, + ], + test_output=[ + ( + "list", + ["Alice", 25, True], + ), + ( + "list", + [1, 2, 3, "four", {"key": "value"}], + ), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + chunk = [] + cur_tokens, max_tokens = 0, input_data.max_tokens + cur_size, max_size = 0, input_data.max_size + + for value in input_data.values: + if max_tokens: + tokens = estimate_token_count_str(value) + else: + tokens = 0 + + # Check if adding this value would exceed either limit + if (max_tokens and (cur_tokens + tokens > max_tokens)) or ( + max_size and (cur_size + 1 > max_size) + ): + yield "list", chunk + chunk = [value] + cur_size, cur_tokens = 1, tokens + else: + chunk.append(value) + cur_size, cur_tokens = cur_size + 1, cur_tokens + tokens + + # Yield final chunk if any + if chunk or not input_data.values: + yield "list", chunk + + +class AddToListBlock(Block): + class Input(BlockSchema): + list: List[Any] = SchemaField( + default_factory=list, + advanced=False, + description="The list to add the entry to. If not provided, a new list will be created.", + ) + entry: Any = SchemaField( + description="The entry to add to the list. Can be of any type (string, int, dict, etc.).", + advanced=False, + default=None, + ) + entries: List[Any] = SchemaField( + default_factory=lambda: list(), + description="The entries to add to the list. This is the batch version of the `entry` field.", + advanced=True, + ) + position: int | None = SchemaField( + default=None, + description="The position to insert the new entry. If not provided, the entry will be appended to the end of the list.", + ) + + class Output(BlockSchema): + updated_list: List[Any] = SchemaField( + description="The list with the new entry added." + ) + error: str = SchemaField(description="Error message if the operation failed.") + + def __init__(self): + super().__init__( + id="aeb08fc1-2fc1-4141-bc8e-f758f183a822", + description="Adds a new entry to a list. The entry can be of any type. If no list is provided, a new one is created.", + categories={BlockCategory.BASIC}, + input_schema=AddToListBlock.Input, + output_schema=AddToListBlock.Output, + test_input=[ + { + "list": [1, "string", {"existing_key": "existing_value"}], + "entry": {"new_key": "new_value"}, + "position": 1, + }, + {"entry": "first_entry"}, + {"list": ["a", "b", "c"], "entry": "d"}, + { + "entry": "e", + "entries": ["f", "g"], + "list": ["a", "b"], + "position": 1, + }, + ], + test_output=[ + ( + "updated_list", + [ + 1, + {"new_key": "new_value"}, + "string", + {"existing_key": "existing_value"}, + ], + ), + ("updated_list", ["first_entry"]), + ("updated_list", ["a", "b", "c", "d"]), + ("updated_list", ["a", "f", "g", "e", "b"]), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + entries_added = input_data.entries.copy() + if input_data.entry: + entries_added.append(input_data.entry) + + updated_list = input_data.list.copy() + if (pos := input_data.position) is not None: + updated_list = updated_list[:pos] + entries_added + updated_list[pos:] + else: + updated_list += entries_added + + yield "updated_list", updated_list + + +class FindInListBlock(Block): + class Input(BlockSchema): + list: List[Any] = SchemaField(description="The list to search in.") + value: Any = SchemaField(description="The value to search for.") + + class Output(BlockSchema): + index: int = SchemaField(description="The index of the value in the list.") + found: bool = SchemaField( + description="Whether the value was found in the list." + ) + not_found_value: Any = SchemaField( + description="The value that was not found in the list." + ) + + def __init__(self): + super().__init__( + id="5e2c6d0a-1e37-489f-b1d0-8e1812b23333", + description="Finds the index of the value in the list.", + categories={BlockCategory.BASIC}, + input_schema=FindInListBlock.Input, + output_schema=FindInListBlock.Output, + test_input=[ + {"list": [1, 2, 3, 4, 5], "value": 3}, + {"list": [1, 2, 3, 4, 5], "value": 6}, + ], + test_output=[ + ("index", 2), + ("found", True), + ("found", False), + ("not_found_value", 6), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + try: + yield "index", input_data.list.index(input_data.value) + yield "found", True + except ValueError: + yield "found", False + yield "not_found_value", input_data.value + + +class GetListItemBlock(Block): + class Input(BlockSchema): + list: List[Any] = SchemaField(description="The list to get the item from.") + index: int = SchemaField( + description="The 0-based index of the item (supports negative indices)." + ) + + class Output(BlockSchema): + item: Any = SchemaField(description="The item at the specified index.") + error: str = SchemaField(description="Error message if the operation failed.") + + def __init__(self): + super().__init__( + id="262ca24c-1025-43cf-a578-534e23234e97", + description="Returns the element at the given index.", + categories={BlockCategory.BASIC}, + input_schema=GetListItemBlock.Input, + output_schema=GetListItemBlock.Output, + test_input=[ + {"list": [1, 2, 3], "index": 1}, + {"list": [1, 2, 3], "index": -1}, + ], + test_output=[ + ("item", 2), + ("item", 3), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + try: + yield "item", input_data.list[input_data.index] + except IndexError: + yield "error", "Index out of range" + + +class RemoveFromListBlock(Block): + class Input(BlockSchema): + list: List[Any] = SchemaField(description="The list to modify.") + value: Any = SchemaField( + default=None, description="Value to remove from the list." + ) + index: int | None = SchemaField( + default=None, + description="Index of the item to pop (supports negative indices).", + ) + return_item: bool = SchemaField( + default=False, description="Whether to return the removed item." + ) + + class Output(BlockSchema): + updated_list: List[Any] = SchemaField(description="The list after removal.") + removed_item: Any = SchemaField(description="The removed item if requested.") + error: str = SchemaField(description="Error message if the operation failed.") + + def __init__(self): + super().__init__( + id="d93c5a93-ac7e-41c1-ae5c-ef67e6e9b826", + description="Removes an item from a list by value or index.", + categories={BlockCategory.BASIC}, + input_schema=RemoveFromListBlock.Input, + output_schema=RemoveFromListBlock.Output, + test_input=[ + {"list": [1, 2, 3], "index": 1, "return_item": True}, + {"list": ["a", "b", "c"], "value": "b"}, + ], + test_output=[ + ("updated_list", [1, 3]), + ("removed_item", 2), + ("updated_list", ["a", "c"]), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + lst = input_data.list.copy() + removed = None + try: + if input_data.index is not None: + removed = lst.pop(input_data.index) + elif input_data.value is not None: + lst.remove(input_data.value) + removed = input_data.value + else: + raise ValueError("No index or value provided for removal") + except (IndexError, ValueError): + yield "error", "Index or value not found" + return + + yield "updated_list", lst + if input_data.return_item: + yield "removed_item", removed + + +class ReplaceListItemBlock(Block): + class Input(BlockSchema): + list: List[Any] = SchemaField(description="The list to modify.") + index: int = SchemaField( + description="Index of the item to replace (supports negative indices)." + ) + value: Any = SchemaField(description="The new value for the given index.") + + class Output(BlockSchema): + updated_list: List[Any] = SchemaField(description="The list after replacement.") + old_item: Any = SchemaField(description="The item that was replaced.") + error: str = SchemaField(description="Error message if the operation failed.") + + def __init__(self): + super().__init__( + id="fbf62922-bea1-4a3d-8bac-23587f810b38", + description="Replaces an item at the specified index.", + categories={BlockCategory.BASIC}, + input_schema=ReplaceListItemBlock.Input, + output_schema=ReplaceListItemBlock.Output, + test_input=[ + {"list": [1, 2, 3], "index": 1, "value": 99}, + {"list": ["a", "b"], "index": -1, "value": "c"}, + ], + test_output=[ + ("updated_list", [1, 99, 3]), + ("old_item", 2), + ("updated_list", ["a", "c"]), + ("old_item", "b"), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + lst = input_data.list.copy() + try: + old = lst[input_data.index] + lst[input_data.index] = input_data.value + except IndexError: + yield "error", "Index out of range" + return + + yield "updated_list", lst + yield "old_item", old + + +class ListIsEmptyBlock(Block): + class Input(BlockSchema): + list: List[Any] = SchemaField(description="The list to check.") + + class Output(BlockSchema): + is_empty: bool = SchemaField(description="True if the list is empty.") + + def __init__(self): + super().__init__( + id="896ed73b-27d0-41be-813c-c1c1dc856c03", + description="Checks if a list is empty.", + categories={BlockCategory.BASIC}, + input_schema=ListIsEmptyBlock.Input, + output_schema=ListIsEmptyBlock.Output, + test_input=[{"list": []}, {"list": [1]}], + test_output=[("is_empty", True), ("is_empty", False)], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + yield "is_empty", len(input_data.list) == 0 diff --git a/autogpt_platform/backend/backend/blocks/dataforseo/__init__.py b/autogpt_platform/backend/backend/blocks/dataforseo/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/blocks/dataforseo/_api.py b/autogpt_platform/backend/backend/blocks/dataforseo/_api.py new file mode 100644 index 000000000000..025b322e4810 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/dataforseo/_api.py @@ -0,0 +1,178 @@ +""" +DataForSEO API client with async support using the SDK patterns. +""" + +import base64 +from typing import Any, Dict, List, Optional + +from backend.sdk import Requests, UserPasswordCredentials + + +class DataForSeoClient: + """Client for the DataForSEO API using async requests.""" + + API_URL = "https://api.dataforseo.com" + + def __init__(self, credentials: UserPasswordCredentials): + self.credentials = credentials + self.requests = Requests( + trusted_origins=["https://api.dataforseo.com"], + raise_for_status=False, + ) + + def _get_headers(self) -> Dict[str, str]: + """Generate the authorization header using Basic Auth.""" + username = self.credentials.username.get_secret_value() + password = self.credentials.password.get_secret_value() + credentials_str = f"{username}:{password}" + encoded = base64.b64encode(credentials_str.encode("ascii")).decode("ascii") + return { + "Authorization": f"Basic {encoded}", + "Content-Type": "application/json", + } + + async def keyword_suggestions( + self, + keyword: str, + location_code: Optional[int] = None, + language_code: Optional[str] = None, + include_seed_keyword: bool = True, + include_serp_info: bool = False, + include_clickstream_data: bool = False, + limit: int = 100, + ) -> List[Dict[str, Any]]: + """ + Get keyword suggestions from DataForSEO Labs. + + Args: + keyword: Seed keyword + location_code: Location code for targeting + language_code: Language code (e.g., "en") + include_seed_keyword: Include seed keyword in results + include_serp_info: Include SERP data + include_clickstream_data: Include clickstream metrics + limit: Maximum number of results (up to 3000) + + Returns: + API response with keyword suggestions + """ + endpoint = f"{self.API_URL}/v3/dataforseo_labs/google/keyword_suggestions/live" + + # Build payload only with non-None values to avoid sending null fields + task_data: dict[str, Any] = { + "keyword": keyword, + } + + if location_code is not None: + task_data["location_code"] = location_code + if language_code is not None: + task_data["language_code"] = language_code + if include_seed_keyword is not None: + task_data["include_seed_keyword"] = include_seed_keyword + if include_serp_info is not None: + task_data["include_serp_info"] = include_serp_info + if include_clickstream_data is not None: + task_data["include_clickstream_data"] = include_clickstream_data + if limit is not None: + task_data["limit"] = limit + + payload = [task_data] + + response = await self.requests.post( + endpoint, + headers=self._get_headers(), + json=payload, + ) + + data = response.json() + + # Check for API errors + if response.status != 200: + error_message = data.get("status_message", "Unknown error") + raise Exception( + f"DataForSEO API error ({response.status}): {error_message}" + ) + + # Extract the results from the response + if data.get("tasks") and len(data["tasks"]) > 0: + task = data["tasks"][0] + if task.get("status_code") == 20000: # Success code + return task.get("result", []) + else: + error_msg = task.get("status_message", "Task failed") + raise Exception(f"DataForSEO task error: {error_msg}") + + return [] + + async def related_keywords( + self, + keyword: str, + location_code: Optional[int] = None, + language_code: Optional[str] = None, + include_seed_keyword: bool = True, + include_serp_info: bool = False, + include_clickstream_data: bool = False, + limit: int = 100, + ) -> List[Dict[str, Any]]: + """ + Get related keywords from DataForSEO Labs. + + Args: + keyword: Seed keyword + location_code: Location code for targeting + language_code: Language code (e.g., "en") + include_seed_keyword: Include seed keyword in results + include_serp_info: Include SERP data + include_clickstream_data: Include clickstream metrics + limit: Maximum number of results (up to 3000) + + Returns: + API response with related keywords + """ + endpoint = f"{self.API_URL}/v3/dataforseo_labs/google/related_keywords/live" + + # Build payload only with non-None values to avoid sending null fields + task_data: dict[str, Any] = { + "keyword": keyword, + } + + if location_code is not None: + task_data["location_code"] = location_code + if language_code is not None: + task_data["language_code"] = language_code + if include_seed_keyword is not None: + task_data["include_seed_keyword"] = include_seed_keyword + if include_serp_info is not None: + task_data["include_serp_info"] = include_serp_info + if include_clickstream_data is not None: + task_data["include_clickstream_data"] = include_clickstream_data + if limit is not None: + task_data["limit"] = limit + + payload = [task_data] + + response = await self.requests.post( + endpoint, + headers=self._get_headers(), + json=payload, + ) + + data = response.json() + + # Check for API errors + if response.status != 200: + error_message = data.get("status_message", "Unknown error") + raise Exception( + f"DataForSEO API error ({response.status}): {error_message}" + ) + + # Extract the results from the response + if data.get("tasks") and len(data["tasks"]) > 0: + task = data["tasks"][0] + if task.get("status_code") == 20000: # Success code + return task.get("result", []) + else: + error_msg = task.get("status_message", "Task failed") + raise Exception(f"DataForSEO task error: {error_msg}") + + return [] diff --git a/autogpt_platform/backend/backend/blocks/dataforseo/_config.py b/autogpt_platform/backend/backend/blocks/dataforseo/_config.py new file mode 100644 index 000000000000..10b2b91130c2 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/dataforseo/_config.py @@ -0,0 +1,17 @@ +""" +Configuration for all DataForSEO blocks using the new SDK pattern. +""" + +from backend.sdk import BlockCostType, ProviderBuilder + +# Build the DataForSEO provider with username/password authentication +dataforseo = ( + ProviderBuilder("dataforseo") + .with_user_password( + username_env_var="DATAFORSEO_USERNAME", + password_env_var="DATAFORSEO_PASSWORD", + title="DataForSEO Credentials", + ) + .with_base_cost(1, BlockCostType.RUN) + .build() +) diff --git a/autogpt_platform/backend/backend/blocks/dataforseo/keyword_suggestions.py b/autogpt_platform/backend/backend/blocks/dataforseo/keyword_suggestions.py new file mode 100644 index 000000000000..ac36215bc72e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/dataforseo/keyword_suggestions.py @@ -0,0 +1,273 @@ +""" +DataForSEO Google Keyword Suggestions block. +""" + +from typing import Any, Dict, List, Optional + +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, + UserPasswordCredentials, +) + +from ._api import DataForSeoClient +from ._config import dataforseo + + +class KeywordSuggestion(BlockSchema): + """Schema for a keyword suggestion result.""" + + keyword: str = SchemaField(description="The keyword suggestion") + search_volume: Optional[int] = SchemaField( + description="Monthly search volume", default=None + ) + competition: Optional[float] = SchemaField( + description="Competition level (0-1)", default=None + ) + cpc: Optional[float] = SchemaField( + description="Cost per click in USD", default=None + ) + keyword_difficulty: Optional[int] = SchemaField( + description="Keyword difficulty score", default=None + ) + serp_info: Optional[Dict[str, Any]] = SchemaField( + description="data from SERP for each keyword", default=None + ) + clickstream_data: Optional[Dict[str, Any]] = SchemaField( + description="Clickstream data metrics", default=None + ) + + +class DataForSeoKeywordSuggestionsBlock(Block): + """Block for getting keyword suggestions from DataForSEO Labs.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = dataforseo.credentials_field( + description="DataForSEO credentials (username and password)" + ) + keyword: str = SchemaField(description="Seed keyword to get suggestions for") + location_code: Optional[int] = SchemaField( + description="Location code for targeting (e.g., 2840 for USA)", + default=2840, # USA + ) + language_code: Optional[str] = SchemaField( + description="Language code (e.g., 'en' for English)", + default="en", + ) + include_seed_keyword: bool = SchemaField( + description="Include the seed keyword in results", + default=True, + ) + include_serp_info: bool = SchemaField( + description="Include SERP information", + default=False, + ) + include_clickstream_data: bool = SchemaField( + description="Include clickstream metrics", + default=False, + ) + limit: int = SchemaField( + description="Maximum number of results (up to 3000)", + default=100, + ge=1, + le=3000, + ) + + class Output(BlockSchema): + suggestions: List[KeywordSuggestion] = SchemaField( + description="List of keyword suggestions with metrics" + ) + suggestion: KeywordSuggestion = SchemaField( + description="A single keyword suggestion with metrics" + ) + total_count: int = SchemaField( + description="Total number of suggestions returned" + ) + seed_keyword: str = SchemaField( + description="The seed keyword used for the query" + ) + + def __init__(self): + super().__init__( + id="73c3e7c4-2b3f-4e9f-9e3e-8f7a5c3e2d45", + description="Get keyword suggestions from DataForSEO Labs Google API", + categories={BlockCategory.SEARCH, BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + test_input={ + "credentials": dataforseo.get_test_credentials().model_dump(), + "keyword": "digital marketing", + "location_code": 2840, + "language_code": "en", + "limit": 1, + }, + test_credentials=dataforseo.get_test_credentials(), + test_output=[ + ( + "suggestion", + lambda x: hasattr(x, "keyword") + and x.keyword == "digital marketing strategy", + ), + ("suggestions", lambda x: isinstance(x, list) and len(x) == 1), + ("total_count", 1), + ("seed_keyword", "digital marketing"), + ], + test_mock={ + "_fetch_keyword_suggestions": lambda *args, **kwargs: [ + { + "items": [ + { + "keyword": "digital marketing strategy", + "keyword_info": { + "search_volume": 10000, + "competition": 0.5, + "cpc": 2.5, + }, + "keyword_properties": { + "keyword_difficulty": 50, + }, + } + ] + } + ] + }, + ) + + async def _fetch_keyword_suggestions( + self, + client: DataForSeoClient, + input_data: Input, + ) -> Any: + """Private method to fetch keyword suggestions - can be mocked for testing.""" + return await client.keyword_suggestions( + keyword=input_data.keyword, + location_code=input_data.location_code, + language_code=input_data.language_code, + include_seed_keyword=input_data.include_seed_keyword, + include_serp_info=input_data.include_serp_info, + include_clickstream_data=input_data.include_clickstream_data, + limit=input_data.limit, + ) + + async def run( + self, + input_data: Input, + *, + credentials: UserPasswordCredentials, + **kwargs, + ) -> BlockOutput: + """Execute the keyword suggestions query.""" + client = DataForSeoClient(credentials) + + results = await self._fetch_keyword_suggestions(client, input_data) + + # Process and format the results + suggestions = [] + if results and len(results) > 0: + # results is a list, get the first element + first_result = results[0] if isinstance(results, list) else results + items = ( + first_result.get("items", []) if isinstance(first_result, dict) else [] + ) + for item in items: + # Create the KeywordSuggestion object + suggestion = KeywordSuggestion( + keyword=item.get("keyword", ""), + search_volume=item.get("keyword_info", {}).get("search_volume"), + competition=item.get("keyword_info", {}).get("competition"), + cpc=item.get("keyword_info", {}).get("cpc"), + keyword_difficulty=item.get("keyword_properties", {}).get( + "keyword_difficulty" + ), + serp_info=( + item.get("serp_info") if input_data.include_serp_info else None + ), + clickstream_data=( + item.get("clickstream_keyword_info") + if input_data.include_clickstream_data + else None + ), + ) + yield "suggestion", suggestion + suggestions.append(suggestion) + + yield "suggestions", suggestions + yield "total_count", len(suggestions) + yield "seed_keyword", input_data.keyword + + +class KeywordSuggestionExtractorBlock(Block): + """Extracts individual fields from a KeywordSuggestion object.""" + + class Input(BlockSchema): + suggestion: KeywordSuggestion = SchemaField( + description="The keyword suggestion object to extract fields from" + ) + + class Output(BlockSchema): + keyword: str = SchemaField(description="The keyword suggestion") + search_volume: Optional[int] = SchemaField( + description="Monthly search volume", default=None + ) + competition: Optional[float] = SchemaField( + description="Competition level (0-1)", default=None + ) + cpc: Optional[float] = SchemaField( + description="Cost per click in USD", default=None + ) + keyword_difficulty: Optional[int] = SchemaField( + description="Keyword difficulty score", default=None + ) + serp_info: Optional[Dict[str, Any]] = SchemaField( + description="data from SERP for each keyword", default=None + ) + clickstream_data: Optional[Dict[str, Any]] = SchemaField( + description="Clickstream data metrics", default=None + ) + + def __init__(self): + super().__init__( + id="4193cb94-677c-48b0-9eec-6ac72fffd0f2", + description="Extract individual fields from a KeywordSuggestion object", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + test_input={ + "suggestion": KeywordSuggestion( + keyword="test keyword", + search_volume=1000, + competition=0.5, + cpc=2.5, + keyword_difficulty=60, + ).model_dump() + }, + test_output=[ + ("keyword", "test keyword"), + ("search_volume", 1000), + ("competition", 0.5), + ("cpc", 2.5), + ("keyword_difficulty", 60), + ("serp_info", None), + ("clickstream_data", None), + ], + ) + + async def run( + self, + input_data: Input, + **kwargs, + ) -> BlockOutput: + """Extract fields from the KeywordSuggestion object.""" + suggestion = input_data.suggestion + + yield "keyword", suggestion.keyword + yield "search_volume", suggestion.search_volume + yield "competition", suggestion.competition + yield "cpc", suggestion.cpc + yield "keyword_difficulty", suggestion.keyword_difficulty + yield "serp_info", suggestion.serp_info + yield "clickstream_data", suggestion.clickstream_data diff --git a/autogpt_platform/backend/backend/blocks/dataforseo/related_keywords.py b/autogpt_platform/backend/backend/blocks/dataforseo/related_keywords.py new file mode 100644 index 000000000000..ae0ecf93e386 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/dataforseo/related_keywords.py @@ -0,0 +1,283 @@ +""" +DataForSEO Google Related Keywords block. +""" + +from typing import Any, Dict, List, Optional + +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, + UserPasswordCredentials, +) + +from ._api import DataForSeoClient +from ._config import dataforseo + + +class RelatedKeyword(BlockSchema): + """Schema for a related keyword result.""" + + keyword: str = SchemaField(description="The related keyword") + search_volume: Optional[int] = SchemaField( + description="Monthly search volume", default=None + ) + competition: Optional[float] = SchemaField( + description="Competition level (0-1)", default=None + ) + cpc: Optional[float] = SchemaField( + description="Cost per click in USD", default=None + ) + keyword_difficulty: Optional[int] = SchemaField( + description="Keyword difficulty score", default=None + ) + serp_info: Optional[Dict[str, Any]] = SchemaField( + description="SERP data for the keyword", default=None + ) + clickstream_data: Optional[Dict[str, Any]] = SchemaField( + description="Clickstream data metrics", default=None + ) + + +class DataForSeoRelatedKeywordsBlock(Block): + """Block for getting related keywords from DataForSEO Labs.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = dataforseo.credentials_field( + description="DataForSEO credentials (username and password)" + ) + keyword: str = SchemaField( + description="Seed keyword to find related keywords for" + ) + location_code: Optional[int] = SchemaField( + description="Location code for targeting (e.g., 2840 for USA)", + default=2840, # USA + ) + language_code: Optional[str] = SchemaField( + description="Language code (e.g., 'en' for English)", + default="en", + ) + include_seed_keyword: bool = SchemaField( + description="Include the seed keyword in results", + default=True, + ) + include_serp_info: bool = SchemaField( + description="Include SERP information", + default=False, + ) + include_clickstream_data: bool = SchemaField( + description="Include clickstream metrics", + default=False, + ) + limit: int = SchemaField( + description="Maximum number of results (up to 3000)", + default=100, + ge=1, + le=3000, + ) + + class Output(BlockSchema): + related_keywords: List[RelatedKeyword] = SchemaField( + description="List of related keywords with metrics" + ) + related_keyword: RelatedKeyword = SchemaField( + description="A related keyword with metrics" + ) + total_count: int = SchemaField( + description="Total number of related keywords returned" + ) + seed_keyword: str = SchemaField( + description="The seed keyword used for the query" + ) + + def __init__(self): + super().__init__( + id="8f2e4d6a-1b3c-4a5e-9d7f-2c8e6a4b3f1d", + description="Get related keywords from DataForSEO Labs Google API", + categories={BlockCategory.SEARCH, BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + test_input={ + "credentials": dataforseo.get_test_credentials().model_dump(), + "keyword": "content marketing", + "location_code": 2840, + "language_code": "en", + "limit": 1, + }, + test_credentials=dataforseo.get_test_credentials(), + test_output=[ + ( + "related_keyword", + lambda x: hasattr(x, "keyword") and x.keyword == "content strategy", + ), + ("related_keywords", lambda x: isinstance(x, list) and len(x) == 1), + ("total_count", 1), + ("seed_keyword", "content marketing"), + ], + test_mock={ + "_fetch_related_keywords": lambda *args, **kwargs: [ + { + "items": [ + { + "keyword_data": { + "keyword": "content strategy", + "keyword_info": { + "search_volume": 8000, + "competition": 0.4, + "cpc": 3.0, + }, + "keyword_properties": { + "keyword_difficulty": 45, + }, + } + } + ] + } + ] + }, + ) + + async def _fetch_related_keywords( + self, + client: DataForSeoClient, + input_data: Input, + ) -> Any: + """Private method to fetch related keywords - can be mocked for testing.""" + return await client.related_keywords( + keyword=input_data.keyword, + location_code=input_data.location_code, + language_code=input_data.language_code, + include_seed_keyword=input_data.include_seed_keyword, + include_serp_info=input_data.include_serp_info, + include_clickstream_data=input_data.include_clickstream_data, + limit=input_data.limit, + ) + + async def run( + self, + input_data: Input, + *, + credentials: UserPasswordCredentials, + **kwargs, + ) -> BlockOutput: + """Execute the related keywords query.""" + client = DataForSeoClient(credentials) + + results = await self._fetch_related_keywords(client, input_data) + + # Process and format the results + related_keywords = [] + if results and len(results) > 0: + # results is a list, get the first element + first_result = results[0] if isinstance(results, list) else results + items = ( + first_result.get("items", []) if isinstance(first_result, dict) else [] + ) + for item in items: + # Extract keyword_data from the item + keyword_data = item.get("keyword_data", {}) + + # Create the RelatedKeyword object + keyword = RelatedKeyword( + keyword=keyword_data.get("keyword", ""), + search_volume=keyword_data.get("keyword_info", {}).get( + "search_volume" + ), + competition=keyword_data.get("keyword_info", {}).get("competition"), + cpc=keyword_data.get("keyword_info", {}).get("cpc"), + keyword_difficulty=keyword_data.get("keyword_properties", {}).get( + "keyword_difficulty" + ), + serp_info=( + keyword_data.get("serp_info") + if input_data.include_serp_info + else None + ), + clickstream_data=( + keyword_data.get("clickstream_keyword_info") + if input_data.include_clickstream_data + else None + ), + ) + yield "related_keyword", keyword + related_keywords.append(keyword) + + yield "related_keywords", related_keywords + yield "total_count", len(related_keywords) + yield "seed_keyword", input_data.keyword + + +class RelatedKeywordExtractorBlock(Block): + """Extracts individual fields from a RelatedKeyword object.""" + + class Input(BlockSchema): + related_keyword: RelatedKeyword = SchemaField( + description="The related keyword object to extract fields from" + ) + + class Output(BlockSchema): + keyword: str = SchemaField(description="The related keyword") + search_volume: Optional[int] = SchemaField( + description="Monthly search volume", default=None + ) + competition: Optional[float] = SchemaField( + description="Competition level (0-1)", default=None + ) + cpc: Optional[float] = SchemaField( + description="Cost per click in USD", default=None + ) + keyword_difficulty: Optional[int] = SchemaField( + description="Keyword difficulty score", default=None + ) + serp_info: Optional[Dict[str, Any]] = SchemaField( + description="SERP data for the keyword", default=None + ) + clickstream_data: Optional[Dict[str, Any]] = SchemaField( + description="Clickstream data metrics", default=None + ) + + def __init__(self): + super().__init__( + id="98342061-09d2-4952-bf77-0761fc8cc9a8", + description="Extract individual fields from a RelatedKeyword object", + categories={BlockCategory.DATA}, + input_schema=self.Input, + output_schema=self.Output, + test_input={ + "related_keyword": RelatedKeyword( + keyword="test related keyword", + search_volume=800, + competition=0.4, + cpc=3.0, + keyword_difficulty=55, + ).model_dump() + }, + test_output=[ + ("keyword", "test related keyword"), + ("search_volume", 800), + ("competition", 0.4), + ("cpc", 3.0), + ("keyword_difficulty", 55), + ("serp_info", None), + ("clickstream_data", None), + ], + ) + + async def run( + self, + input_data: Input, + **kwargs, + ) -> BlockOutput: + """Extract fields from the RelatedKeyword object.""" + related_keyword = input_data.related_keyword + + yield "keyword", related_keyword.keyword + yield "search_volume", related_keyword.search_volume + yield "competition", related_keyword.competition + yield "cpc", related_keyword.cpc + yield "keyword_difficulty", related_keyword.keyword_difficulty + yield "serp_info", related_keyword.serp_info + yield "clickstream_data", related_keyword.clickstream_data diff --git a/autogpt_platform/backend/backend/blocks/decoder_block.py b/autogpt_platform/backend/backend/blocks/decoder_block.py index 033cdfb0b355..754d79b0688a 100644 --- a/autogpt_platform/backend/backend/blocks/decoder_block.py +++ b/autogpt_platform/backend/backend/blocks/decoder_block.py @@ -34,6 +34,6 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: decoded_text = codecs.decode(input_data.text, "unicode_escape") yield "decoded_text", decoded_text diff --git a/autogpt_platform/backend/backend/blocks/discord.py b/autogpt_platform/backend/backend/blocks/discord.py deleted file mode 100644 index 08ba8af074cd..000000000000 --- a/autogpt_platform/backend/backend/blocks/discord.py +++ /dev/null @@ -1,254 +0,0 @@ -import asyncio -from typing import Literal - -import aiohttp -import discord -from pydantic import SecretStr - -from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema -from backend.data.model import ( - APIKeyCredentials, - CredentialsField, - CredentialsMetaInput, - SchemaField, -) -from backend.integrations.providers import ProviderName - -DiscordCredentials = CredentialsMetaInput[ - Literal[ProviderName.DISCORD], Literal["api_key"] -] - - -def DiscordCredentialsField() -> DiscordCredentials: - return CredentialsField(description="Discord bot token") - - -TEST_CREDENTIALS = APIKeyCredentials( - id="01234567-89ab-cdef-0123-456789abcdef", - provider="discord", - api_key=SecretStr("test_api_key"), - title="Mock Discord API key", - expires_at=None, -) -TEST_CREDENTIALS_INPUT = { - "provider": TEST_CREDENTIALS.provider, - "id": TEST_CREDENTIALS.id, - "type": TEST_CREDENTIALS.type, - "title": TEST_CREDENTIALS.type, -} - - -class ReadDiscordMessagesBlock(Block): - class Input(BlockSchema): - credentials: DiscordCredentials = DiscordCredentialsField() - - class Output(BlockSchema): - message_content: str = SchemaField( - description="The content of the message received" - ) - channel_name: str = SchemaField( - description="The name of the channel the message was received from" - ) - username: str = SchemaField( - description="The username of the user who sent the message" - ) - - def __init__(self): - super().__init__( - id="df06086a-d5ac-4abb-9996-2ad0acb2eff7", - input_schema=ReadDiscordMessagesBlock.Input, # Assign input schema - output_schema=ReadDiscordMessagesBlock.Output, # Assign output schema - description="Reads messages from a Discord channel using a bot token.", - categories={BlockCategory.SOCIAL}, - test_input={ - "continuous_read": False, - "credentials": TEST_CREDENTIALS_INPUT, - }, - test_credentials=TEST_CREDENTIALS, - test_output=[ - ( - "message_content", - "Hello!\n\nFile from user: example.txt\nContent: This is the content of the file.", - ), - ("channel_name", "general"), - ("username", "test_user"), - ], - test_mock={ - "run_bot": lambda token: asyncio.Future() # Create a Future object for mocking - }, - ) - - async def run_bot(self, token: SecretStr): - intents = discord.Intents.default() - intents.message_content = True - - client = discord.Client(intents=intents) - - self.output_data = None - self.channel_name = None - self.username = None - - @client.event - async def on_ready(): - print(f"Logged in as {client.user}") - - @client.event - async def on_message(message): - if message.author == client.user: - return - - self.output_data = message.content - self.channel_name = message.channel.name - self.username = message.author.name - - if message.attachments: - attachment = message.attachments[0] # Process the first attachment - if attachment.filename.endswith((".txt", ".py")): - async with aiohttp.ClientSession() as session: - async with session.get(attachment.url) as response: - file_content = await response.text() - self.output_data += f"\n\nFile from user: {attachment.filename}\nContent: {file_content}" - - await client.close() - - await client.start(token.get_secret_value()) - - def run( - self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs - ) -> BlockOutput: - while True: - for output_name, output_value in self.__run(input_data, credentials): - yield output_name, output_value - break - - def __run(self, input_data: Input, credentials: APIKeyCredentials) -> BlockOutput: - try: - loop = asyncio.get_event_loop() - future = self.run_bot(credentials.api_key) - - # If it's a Future (mock), set the result - if isinstance(future, asyncio.Future): - future.set_result( - { - "output_data": "Hello!\n\nFile from user: example.txt\nContent: This is the content of the file.", - "channel_name": "general", - "username": "test_user", - } - ) - - result = loop.run_until_complete(future) - - # For testing purposes, use the mocked result - if isinstance(result, dict): - self.output_data = result.get("output_data") - self.channel_name = result.get("channel_name") - self.username = result.get("username") - - if ( - self.output_data is None - or self.channel_name is None - or self.username is None - ): - raise ValueError("No message, channel name, or username received.") - - yield "message_content", self.output_data - yield "channel_name", self.channel_name - yield "username", self.username - - except discord.errors.LoginFailure as login_err: - raise ValueError(f"Login error occurred: {login_err}") - except Exception as e: - raise ValueError(f"An error occurred: {e}") - - -class SendDiscordMessageBlock(Block): - class Input(BlockSchema): - credentials: DiscordCredentials = DiscordCredentialsField() - message_content: str = SchemaField( - description="The content of the message received" - ) - channel_name: str = SchemaField( - description="The name of the channel the message was received from" - ) - - class Output(BlockSchema): - status: str = SchemaField( - description="The status of the operation (e.g., 'Message sent', 'Error')" - ) - - def __init__(self): - super().__init__( - id="d0822ab5-9f8a-44a3-8971-531dd0178b6b", - input_schema=SendDiscordMessageBlock.Input, # Assign input schema - output_schema=SendDiscordMessageBlock.Output, # Assign output schema - description="Sends a message to a Discord channel using a bot token.", - categories={BlockCategory.SOCIAL}, - test_input={ - "channel_name": "general", - "message_content": "Hello, Discord!", - "credentials": TEST_CREDENTIALS_INPUT, - }, - test_output=[("status", "Message sent")], - test_mock={ - "send_message": lambda token, channel_name, message_content: asyncio.Future() - }, - test_credentials=TEST_CREDENTIALS, - ) - - async def send_message(self, token: str, channel_name: str, message_content: str): - intents = discord.Intents.default() - intents.guilds = True # Required for fetching guild/channel information - client = discord.Client(intents=intents) - - @client.event - async def on_ready(): - print(f"Logged in as {client.user}") - for guild in client.guilds: - for channel in guild.text_channels: - if channel.name == channel_name: - # Split message into chunks if it exceeds 2000 characters - for chunk in self.chunk_message(message_content): - await channel.send(chunk) - self.output_data = "Message sent" - await client.close() - return - - self.output_data = "Channel not found" - await client.close() - - await client.start(token) - - def chunk_message(self, message: str, limit: int = 2000) -> list: - """Splits a message into chunks not exceeding the Discord limit.""" - return [message[i : i + limit] for i in range(0, len(message), limit)] - - def run( - self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs - ) -> BlockOutput: - try: - loop = asyncio.get_event_loop() - future = self.send_message( - credentials.api_key.get_secret_value(), - input_data.channel_name, - input_data.message_content, - ) - - # If it's a Future (mock), set the result - if isinstance(future, asyncio.Future): - future.set_result("Message sent") - - result = loop.run_until_complete(future) - - # For testing purposes, use the mocked result - if isinstance(result, str): - self.output_data = result - - if self.output_data is None: - raise ValueError("No status message received.") - - yield "status", self.output_data - - except discord.errors.LoginFailure as login_err: - raise ValueError(f"Login error occurred: {login_err}") - except Exception as e: - raise ValueError(f"An error occurred: {e}") diff --git a/autogpt_platform/backend/backend/blocks/discord/_api.py b/autogpt_platform/backend/backend/blocks/discord/_api.py new file mode 100644 index 000000000000..3d7a52a39630 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/discord/_api.py @@ -0,0 +1,117 @@ +""" +Discord API helper functions for making authenticated requests. +""" + +import logging +from typing import Optional + +from pydantic import BaseModel + +from backend.data.model import OAuth2Credentials +from backend.util.request import Requests + +logger = logging.getLogger(__name__) + + +class DiscordAPIException(Exception): + """Exception raised for Discord API errors.""" + + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.status_code = status_code + + +class DiscordOAuthUser(BaseModel): + """Model for Discord OAuth user response.""" + + user_id: str + username: str + avatar_url: str + banner: Optional[str] = None + accent_color: Optional[int] = None + + +def get_api(credentials: OAuth2Credentials) -> Requests: + """ + Create a Requests instance configured for Discord API calls with OAuth2 credentials. + + Args: + credentials: The OAuth2 credentials containing the access token. + + Returns: + A configured Requests instance for Discord API calls. + """ + return Requests( + trusted_origins=[], + extra_headers={ + "Authorization": f"Bearer {credentials.access_token.get_secret_value()}", + "Content-Type": "application/json", + }, + raise_for_status=False, + ) + + +async def get_current_user(credentials: OAuth2Credentials) -> DiscordOAuthUser: + """ + Fetch the current user's information using Discord OAuth2 API. + + Reference: https://discord.com/developers/docs/resources/user#get-current-user + + Args: + credentials: The OAuth2 credentials. + + Returns: + A model containing user data with avatar URL. + + Raises: + DiscordAPIException: If the API request fails. + """ + api = get_api(credentials) + response = await api.get("https://discord.com/api/oauth2/@me") + + if not response.ok: + error_text = response.text() + raise DiscordAPIException( + f"Failed to fetch user info: {response.status} - {error_text}", + response.status, + ) + + data = response.json() + logger.info(f"Discord OAuth2 API Response: {data}") + + # The /api/oauth2/@me endpoint returns a user object nested in the response + user_info = data.get("user", {}) + logger.info(f"User info extracted: {user_info}") + + # Build avatar URL + user_id = user_info.get("id") + avatar_hash = user_info.get("avatar") + if avatar_hash: + # Custom avatar + avatar_ext = "gif" if avatar_hash.startswith("a_") else "png" + avatar_url = ( + f"https://cdn.discordapp.com/avatars/{user_id}/{avatar_hash}.{avatar_ext}" + ) + else: + # Default avatar based on discriminator or user ID + discriminator = user_info.get("discriminator", "0") + if discriminator == "0": + # New username system - use user ID for default avatar + default_avatar_index = (int(user_id) >> 22) % 6 + else: + # Legacy discriminator system + default_avatar_index = int(discriminator) % 5 + avatar_url = ( + f"https://cdn.discordapp.com/embed/avatars/{default_avatar_index}.png" + ) + + result = DiscordOAuthUser( + user_id=user_id, + username=user_info.get("username", ""), + avatar_url=avatar_url, + banner=user_info.get("banner"), + accent_color=user_info.get("accent_color"), + ) + + logger.info(f"Returning user data: {result.model_dump()}") + return result diff --git a/autogpt_platform/backend/backend/blocks/discord/_auth.py b/autogpt_platform/backend/backend/blocks/discord/_auth.py new file mode 100644 index 000000000000..e6671a630afc --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/discord/_auth.py @@ -0,0 +1,74 @@ +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import ( + APIKeyCredentials, + CredentialsField, + CredentialsMetaInput, + OAuth2Credentials, +) +from backend.integrations.providers import ProviderName +from backend.util.settings import Secrets + +secrets = Secrets() +DISCORD_OAUTH_IS_CONFIGURED = bool( + secrets.discord_client_id and secrets.discord_client_secret +) + +# Bot token credentials (existing) +DiscordBotCredentials = APIKeyCredentials +DiscordBotCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.DISCORD], Literal["api_key"] +] + +# OAuth2 credentials (new) +DiscordOAuthCredentials = OAuth2Credentials +DiscordOAuthCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.DISCORD], Literal["oauth2"] +] + + +def DiscordBotCredentialsField() -> DiscordBotCredentialsInput: + """Creates a Discord bot token credentials field.""" + return CredentialsField(description="Discord bot token") + + +def DiscordOAuthCredentialsField(scopes: list[str]) -> DiscordOAuthCredentialsInput: + """Creates a Discord OAuth2 credentials field.""" + return CredentialsField( + description="Discord OAuth2 credentials", + required_scopes=set(scopes) | {"identify"}, # Basic user info scope + ) + + +# Test credentials for bot tokens +TEST_BOT_CREDENTIALS = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="discord", + api_key=SecretStr("test_api_key"), + title="Mock Discord API key", + expires_at=None, +) +TEST_BOT_CREDENTIALS_INPUT = { + "provider": TEST_BOT_CREDENTIALS.provider, + "id": TEST_BOT_CREDENTIALS.id, + "type": TEST_BOT_CREDENTIALS.type, + "title": TEST_BOT_CREDENTIALS.type, +} + +# Test credentials for OAuth2 +TEST_OAUTH_CREDENTIALS = OAuth2Credentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="discord", + access_token=SecretStr("test_access_token"), + title="Mock Discord OAuth", + scopes=["identify"], + username="testuser", +) +TEST_OAUTH_CREDENTIALS_INPUT = { + "provider": TEST_OAUTH_CREDENTIALS.provider, + "id": TEST_OAUTH_CREDENTIALS.id, + "type": TEST_OAUTH_CREDENTIALS.type, + "title": TEST_OAUTH_CREDENTIALS.type, +} diff --git a/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py b/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py new file mode 100644 index 000000000000..22469de2d4ea --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/discord/bot_blocks.py @@ -0,0 +1,1140 @@ +import base64 +import io +import mimetypes +from pathlib import Path +from typing import Any + +import aiohttp +import discord +from pydantic import SecretStr + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import APIKeyCredentials, SchemaField +from backend.util.file import store_media_file +from backend.util.type import MediaFileType + +from ._auth import ( + TEST_BOT_CREDENTIALS, + TEST_BOT_CREDENTIALS_INPUT, + DiscordBotCredentialsField, + DiscordBotCredentialsInput, +) + +# Keep backward compatibility alias +DiscordCredentials = DiscordBotCredentialsInput +DiscordCredentialsField = DiscordBotCredentialsField +TEST_CREDENTIALS = TEST_BOT_CREDENTIALS +TEST_CREDENTIALS_INPUT = TEST_BOT_CREDENTIALS_INPUT + + +class ReadDiscordMessagesBlock(Block): + class Input(BlockSchema): + credentials: DiscordCredentials = DiscordCredentialsField() + + class Output(BlockSchema): + message_content: str = SchemaField( + description="The content of the message received" + ) + message_id: str = SchemaField(description="The ID of the message") + channel_id: str = SchemaField(description="The ID of the channel") + channel_name: str = SchemaField( + description="The name of the channel the message was received from" + ) + user_id: str = SchemaField( + description="The ID of the user who sent the message" + ) + username: str = SchemaField( + description="The username of the user who sent the message" + ) + + def __init__(self): + super().__init__( + id="df06086a-d5ac-4abb-9996-2ad0acb2eff7", + input_schema=ReadDiscordMessagesBlock.Input, # Assign input schema + output_schema=ReadDiscordMessagesBlock.Output, # Assign output schema + description="Reads messages from a Discord channel using a bot token.", + categories={BlockCategory.SOCIAL}, + test_input={ + "continuous_read": False, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "message_content", + "Hello!\n\nFile from user: example.txt\nContent: This is the content of the file.", + ), + ("message_id", "123456789012345678"), + ("channel_id", "987654321098765432"), + ("channel_name", "general"), + ("user_id", "111222333444555666"), + ("username", "test_user"), + ], + test_mock={ + "run_bot": lambda token: { + "output_data": "Hello!\n\nFile from user: example.txt\nContent: This is the content of the file.", + "message_id": "123456789012345678", + "channel_id": "987654321098765432", + "channel_name": "general", + "user_id": "111222333444555666", + "username": "test_user", + } + }, + ) + + async def run_bot(self, token: SecretStr): + intents = discord.Intents.default() + intents.message_content = True + + client = discord.Client(intents=intents) + + self.output_data = None + self.message_id = None + self.channel_id = None + self.channel_name = None + self.user_id = None + self.username = None + + @client.event + async def on_ready(): + print(f"Logged in as {client.user}") + + @client.event + async def on_message(message): + if message.author == client.user: + return + + self.output_data = message.content + self.message_id = str(message.id) + self.channel_id = str(message.channel.id) + self.channel_name = message.channel.name + self.user_id = str(message.author.id) + self.username = message.author.name + + if message.attachments: + attachment = message.attachments[0] # Process the first attachment + if attachment.filename.endswith((".txt", ".py")): + async with aiohttp.ClientSession() as session: + async with session.get(attachment.url) as response: + file_content = response.text() + self.output_data += f"\n\nFile from user: {attachment.filename}\nContent: {file_content}" + + await client.close() + + await client.start(token.get_secret_value()) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + async for output_name, output_value in self.__run(input_data, credentials): + yield output_name, output_value + + async def __run( + self, input_data: Input, credentials: APIKeyCredentials + ) -> BlockOutput: + try: + result = await self.run_bot(credentials.api_key) + + # For testing purposes, use the mocked result + if isinstance(result, dict): + self.output_data = result.get("output_data") + self.message_id = result.get("message_id") + self.channel_id = result.get("channel_id") + self.channel_name = result.get("channel_name") + self.user_id = result.get("user_id") + self.username = result.get("username") + + if ( + self.output_data is None + or self.channel_name is None + or self.username is None + ): + raise ValueError("No message, channel name, or username received.") + + yield "message_content", self.output_data + yield "message_id", self.message_id + yield "channel_id", self.channel_id + yield "channel_name", self.channel_name + yield "user_id", self.user_id + yield "username", self.username + + except discord.errors.LoginFailure as login_err: + raise ValueError(f"Login error occurred: {login_err}") + except Exception as e: + raise ValueError(f"An error occurred: {e}") + + +class SendDiscordMessageBlock(Block): + class Input(BlockSchema): + credentials: DiscordCredentials = DiscordCredentialsField() + message_content: str = SchemaField( + description="The content of the message to send" + ) + channel_name: str = SchemaField( + description="The name of the channel the message will be sent to" + ) + server_name: str = SchemaField( + description="The name of the server where the channel is located", + advanced=True, # Optional field for server name + default="", + ) + + class Output(BlockSchema): + status: str = SchemaField( + description="The status of the operation (e.g., 'Message sent', 'Error')" + ) + message_id: str = SchemaField(description="The ID of the sent message") + channel_id: str = SchemaField( + description="The ID of the channel where the message was sent" + ) + + def __init__(self): + super().__init__( + id="d0822ab5-9f8a-44a3-8971-531dd0178b6b", + input_schema=SendDiscordMessageBlock.Input, # Assign input schema + output_schema=SendDiscordMessageBlock.Output, # Assign output schema + description="Sends a message to a Discord channel using a bot token.", + categories={BlockCategory.SOCIAL}, + test_input={ + "channel_name": "general", + "message_content": "Hello, Discord!", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("status", "Message sent"), + ("message_id", "123456789012345678"), + ("channel_id", "987654321098765432"), + ], + test_mock={ + "send_message": lambda token, channel_name, server_name, message_content: { + "status": "Message sent", + "message_id": "123456789012345678", + "channel_id": "987654321098765432", + } + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def send_message( + self, + token: str, + channel_name: str, + server_name: str | None, + message_content: str, + ) -> dict: + intents = discord.Intents.default() + intents.guilds = True # Required for fetching guild/channel information + client = discord.Client(intents=intents) + + result = {} + + @client.event + async def on_ready(): + print(f"Logged in as {client.user}") + for guild in client.guilds: + if server_name and guild.name != server_name: + continue + for channel in guild.text_channels: + if channel.name == channel_name: + # Split message into chunks if it exceeds 2000 characters + chunks = self.chunk_message(message_content) + last_message = None + for chunk in chunks: + last_message = await channel.send(chunk) + result["status"] = "Message sent" + result["message_id"] = ( + str(last_message.id) if last_message else "" + ) + result["channel_id"] = str(channel.id) + await client.close() + return + + result["status"] = "Channel not found" + await client.close() + + await client.start(token) + return result + + def chunk_message(self, message: str, limit: int = 2000) -> list: + """Splits a message into chunks not exceeding the Discord limit.""" + return [message[i : i + limit] for i in range(0, len(message), limit)] + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + result = await self.send_message( + token=credentials.api_key.get_secret_value(), + channel_name=input_data.channel_name, + server_name=input_data.server_name, + message_content=input_data.message_content, + ) + + # For testing purposes, use the mocked result + if isinstance(result, str): + result = {"status": result} + + yield "status", result.get("status", "Unknown error") + if "message_id" in result: + yield "message_id", result["message_id"] + if "channel_id" in result: + yield "channel_id", result["channel_id"] + + except discord.errors.LoginFailure as login_err: + raise ValueError(f"Login error occurred: {login_err}") + except Exception as e: + raise ValueError(f"An error occurred: {e}") + + +class SendDiscordDMBlock(Block): + class Input(BlockSchema): + credentials: DiscordCredentials = DiscordCredentialsField() + user_id: str = SchemaField( + description="The Discord user ID to send the DM to (e.g., '123456789012345678')" + ) + message_content: str = SchemaField( + description="The content of the direct message to send" + ) + + class Output(BlockSchema): + status: str = SchemaField(description="The status of the operation") + message_id: str = SchemaField(description="The ID of the sent message") + + def __init__(self): + super().__init__( + id="40d71a5a-e268-4060-9ee0-38ae6f225682", + input_schema=SendDiscordDMBlock.Input, + output_schema=SendDiscordDMBlock.Output, + description="Sends a direct message to a Discord user using their user ID.", + categories={BlockCategory.SOCIAL}, + test_input={ + "user_id": "123456789012345678", + "message_content": "Hello! This is a test DM.", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("status", "DM sent successfully"), + ("message_id", "987654321098765432"), + ], + test_mock={ + "send_dm": lambda token, user_id, message_content: { + "status": "DM sent successfully", + "message_id": "987654321098765432", + } + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def send_dm(self, token: str, user_id: str, message_content: str) -> dict: + intents = discord.Intents.default() + intents.dm_messages = True + client = discord.Client(intents=intents) + + result = {} + + @client.event + async def on_ready(): + try: + user = await client.fetch_user(int(user_id)) + message = await user.send(message_content) + result["status"] = "DM sent successfully" + result["message_id"] = str(message.id) + except discord.errors.Forbidden: + result["status"] = ( + "Cannot send DM - user has DMs disabled or bot is blocked" + ) + except discord.errors.NotFound: + result["status"] = f"User with ID {user_id} not found" + except ValueError: + result["status"] = f"Invalid user ID format: {user_id}" + except Exception as e: + result["status"] = f"Error sending DM: {str(e)}" + finally: + await client.close() + + await client.start(token) + return result + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + result = await self.send_dm( + token=credentials.api_key.get_secret_value(), + user_id=input_data.user_id, + message_content=input_data.message_content, + ) + + yield "status", result.get("status", "Unknown error") + if "message_id" in result: + yield "message_id", result["message_id"] + + except discord.errors.LoginFailure as login_err: + raise ValueError(f"Login error occurred: {login_err}") + except Exception as e: + raise ValueError(f"An error occurred: {e}") + + +class SendDiscordEmbedBlock(Block): + class Input(BlockSchema): + credentials: DiscordCredentials = DiscordCredentialsField() + channel_identifier: str = SchemaField( + description="Channel ID or channel name to send the embed to" + ) + server_name: str = SchemaField( + description="Server name (only needed if using channel name)", + advanced=True, + default="", + ) + title: str = SchemaField(description="The title of the embed", default="") + description: str = SchemaField( + description="The main content/description of the embed", default="" + ) + color: int = SchemaField( + description="Embed color as integer (e.g., 0x00ff00 for green)", + advanced=True, + default=0x5865F2, # Discord blurple + ) + thumbnail_url: str = SchemaField( + description="URL for the thumbnail image", advanced=True, default="" + ) + image_url: str = SchemaField( + description="URL for the main embed image", advanced=True, default="" + ) + author_name: str = SchemaField( + description="Author name to display", advanced=True, default="" + ) + footer_text: str = SchemaField( + description="Footer text", advanced=True, default="" + ) + fields: list[dict[str, Any]] = SchemaField( + description="List of field dictionaries with 'name', 'value', and optional 'inline' keys", + advanced=True, + default=[], + ) + + class Output(BlockSchema): + status: str = SchemaField(description="Operation status") + message_id: str = SchemaField(description="ID of the sent embed message") + + def __init__(self): + super().__init__( + id="c76293f4-9ae8-454d-a029-0a3f8c5bc499", + input_schema=SendDiscordEmbedBlock.Input, + output_schema=SendDiscordEmbedBlock.Output, + description="Sends a rich embed message to a Discord channel.", + categories={BlockCategory.SOCIAL}, + test_input={ + "channel_identifier": "general", + "title": "Test Embed", + "description": "This is a test embed message", + "color": 0x00FF00, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("status", "Embed sent successfully"), + ("message_id", "123456789012345678"), + ], + test_mock={ + "send_embed": lambda *args, **kwargs: { + "status": "Embed sent successfully", + "message_id": "123456789012345678", + } + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def send_embed( + self, + token: str, + channel_identifier: str, + server_name: str | None, + embed_data: dict, + ) -> dict: + intents = discord.Intents.default() + intents.guilds = True + client = discord.Client(intents=intents) + + result = {} + + @client.event + async def on_ready(): + channel = None + + # Try to parse as channel ID first + try: + channel_id = int(channel_identifier) + channel = client.get_channel(channel_id) + except ValueError: + # Not an ID, treat as channel name + for guild in client.guilds: + if server_name and guild.name != server_name: + continue + for ch in guild.text_channels: + if ch.name == channel_identifier: + channel = ch + break + if channel: + break + + if not channel: + result["status"] = f"Channel not found: {channel_identifier}" + await client.close() + return + + # Build the embed + embed = discord.Embed( + title=embed_data.get("title") or None, + description=embed_data.get("description") or None, + color=embed_data.get("color", 0x5865F2), + ) + + if embed_data.get("thumbnail_url"): + embed.set_thumbnail(url=embed_data["thumbnail_url"]) + + if embed_data.get("image_url"): + embed.set_image(url=embed_data["image_url"]) + + if embed_data.get("author_name"): + embed.set_author(name=embed_data["author_name"]) + + if embed_data.get("footer_text"): + embed.set_footer(text=embed_data["footer_text"]) + + # Add fields + for field in embed_data.get("fields", []): + if isinstance(field, dict) and "name" in field and "value" in field: + embed.add_field( + name=field["name"], + value=field["value"], + inline=field.get("inline", True), + ) + + try: + # Type check - ensure it's a text channel that can send messages + if not hasattr(channel, "send"): + result["status"] = ( + f"Channel {channel_identifier} cannot receive messages (not a text channel)" + ) + await client.close() + return + + message = await channel.send(embed=embed) # type: ignore + result["status"] = "Embed sent successfully" + result["message_id"] = str(message.id) + except Exception as e: + result["status"] = f"Error sending embed: {str(e)}" + finally: + await client.close() + + await client.start(token) + return result + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + embed_data = { + "title": input_data.title, + "description": input_data.description, + "color": input_data.color, + "thumbnail_url": input_data.thumbnail_url, + "image_url": input_data.image_url, + "author_name": input_data.author_name, + "footer_text": input_data.footer_text, + "fields": input_data.fields, + } + + result = await self.send_embed( + token=credentials.api_key.get_secret_value(), + channel_identifier=input_data.channel_identifier, + server_name=input_data.server_name or None, + embed_data=embed_data, + ) + + yield "status", result.get("status", "Unknown error") + if "message_id" in result: + yield "message_id", result["message_id"] + + except discord.errors.LoginFailure as login_err: + raise ValueError(f"Login error occurred: {login_err}") + except Exception as e: + raise ValueError(f"An error occurred: {e}") + + +class SendDiscordFileBlock(Block): + class Input(BlockSchema): + credentials: DiscordCredentials = DiscordCredentialsField() + channel_identifier: str = SchemaField( + description="Channel ID or channel name to send the file to" + ) + server_name: str = SchemaField( + description="Server name (only needed if using channel name)", + advanced=True, + default="", + ) + file: MediaFileType = SchemaField( + description="The file to send (URL, data URI, or local path). Supports images, videos, documents, etc." + ) + filename: str = SchemaField( + description="Name of the file when sent (e.g., 'report.pdf', 'image.png')", + default="", + ) + message_content: str = SchemaField( + description="Optional message to send with the file", default="" + ) + + class Output(BlockSchema): + status: str = SchemaField(description="Operation status") + message_id: str = SchemaField(description="ID of the sent message") + + def __init__(self): + super().__init__( + id="b1628cf2-4622-49bf-80cf-10e55826e247", + input_schema=SendDiscordFileBlock.Input, + output_schema=SendDiscordFileBlock.Output, + description="Sends a file attachment to a Discord channel.", + categories={BlockCategory.SOCIAL}, + test_input={ + "channel_identifier": "general", + "file": "data:text/plain;base64,VGVzdCBmaWxlIGNvbnRlbnQ=", + "filename": "test.txt", + "message_content": "Here's the file!", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("status", "File sent successfully"), + ("message_id", "123456789012345678"), + ], + test_mock={ + "send_file": lambda *args, **kwargs: { + "status": "File sent successfully", + "message_id": "123456789012345678", + } + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def send_file( + self, + token: str, + channel_identifier: str, + server_name: str | None, + file: MediaFileType, + filename: str, + message_content: str, + graph_exec_id: str, + user_id: str, + ) -> dict: + intents = discord.Intents.default() + intents.guilds = True + client = discord.Client(intents=intents) + + result = {} + + @client.event + async def on_ready(): + channel = None + + # Try to parse as channel ID first + try: + channel_id = int(channel_identifier) + channel = client.get_channel(channel_id) + except ValueError: + # Not an ID, treat as channel name + for guild in client.guilds: + if server_name and guild.name != server_name: + continue + for ch in guild.text_channels: + if ch.name == channel_identifier: + channel = ch + break + if channel: + break + + if not channel: + result["status"] = f"Channel not found: {channel_identifier}" + await client.close() + return + + try: + # Handle MediaFileType - could be data URI, URL, or local path + file_bytes = None + detected_filename = filename + + if file.startswith("data:"): + # Data URI - extract the base64 content + header, encoded = file.split(",", 1) + file_bytes = base64.b64decode(encoded) + + # Try to get MIME type and suggest filename if not provided + if not filename and ";" in header: + mime_match = header.split(":")[1].split(";")[0] + ext = mimetypes.guess_extension(mime_match) or ".bin" + detected_filename = f"file{ext}" + + elif file.startswith(("http://", "https://")): + # URL - download the file + async with aiohttp.ClientSession() as session: + async with session.get(file) as response: + file_bytes = await response.read() + + # Try to get filename from URL if not provided + if not filename: + from urllib.parse import urlparse + + path = urlparse(file).path + detected_filename = Path(path).name or "download" + else: + # Local file path - read from stored media file + # This would be a path from a previous block's output + stored_file = await store_media_file( + graph_exec_id=graph_exec_id, + file=file, + user_id=user_id, + return_content=True, # Get as data URI + ) + # Now process as data URI + header, encoded = stored_file.split(",", 1) + file_bytes = base64.b64decode(encoded) + + if not filename: + detected_filename = Path(file).name or "file" + + if not file_bytes: + result["status"] = "Error: Could not read file content" + await client.close() + return + + # Create Discord file object + discord_file = discord.File( + io.BytesIO(file_bytes), filename=detected_filename or "file" + ) + + # Type check - ensure it's a text channel that can send messages + if not hasattr(channel, "send"): + result["status"] = ( + f"Channel {channel_identifier} cannot receive messages (not a text channel)" + ) + await client.close() + return + + # Send the file + message = await channel.send( # type: ignore + content=message_content if message_content else None, + file=discord_file, + ) + result["status"] = "File sent successfully" + result["message_id"] = str(message.id) + except Exception as e: + result["status"] = f"Error sending file: {str(e)}" + finally: + await client.close() + + await client.start(token) + return result + + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + try: + result = await self.send_file( + token=credentials.api_key.get_secret_value(), + channel_identifier=input_data.channel_identifier, + server_name=input_data.server_name or None, + file=input_data.file, + filename=input_data.filename, + message_content=input_data.message_content, + graph_exec_id=graph_exec_id, + user_id=user_id, + ) + + yield "status", result.get("status", "Unknown error") + if "message_id" in result: + yield "message_id", result["message_id"] + + except discord.errors.LoginFailure as login_err: + raise ValueError(f"Login error occurred: {login_err}") + except Exception as e: + raise ValueError(f"An error occurred: {e}") + + +class ReplyToDiscordMessageBlock(Block): + class Input(BlockSchema): + credentials: DiscordCredentials = DiscordCredentialsField() + channel_id: str = SchemaField( + description="The channel ID where the message to reply to is located" + ) + message_id: str = SchemaField(description="The ID of the message to reply to") + reply_content: str = SchemaField(description="The content of the reply") + mention_author: bool = SchemaField( + description="Whether to mention the original message author", default=True + ) + + class Output(BlockSchema): + status: str = SchemaField(description="Operation status") + reply_id: str = SchemaField(description="ID of the reply message") + + def __init__(self): + super().__init__( + id="7226cb99-6e7b-4672-b6b2-acec95336eec", + input_schema=ReplyToDiscordMessageBlock.Input, + output_schema=ReplyToDiscordMessageBlock.Output, + description="Replies to a specific Discord message.", + categories={BlockCategory.SOCIAL}, + test_input={ + "channel_id": "123456789012345678", + "message_id": "987654321098765432", + "reply_content": "This is a reply!", + "mention_author": True, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("status", "Reply sent successfully"), + ("reply_id", "111222333444555666"), + ], + test_mock={ + "send_reply": lambda *args, **kwargs: { + "status": "Reply sent successfully", + "reply_id": "111222333444555666", + } + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def send_reply( + self, + token: str, + channel_id: str, + message_id: str, + reply_content: str, + mention_author: bool, + ) -> dict: + intents = discord.Intents.default() + intents.guilds = True + intents.message_content = True + client = discord.Client(intents=intents) + + result = {} + + @client.event + async def on_ready(): + try: + channel = client.get_channel(int(channel_id)) + if not channel: + channel = await client.fetch_channel(int(channel_id)) + + if not channel: + result["status"] = f"Channel with ID {channel_id} not found" + await client.close() + return + + # Type check - ensure it's a text channel that can fetch messages + if not hasattr(channel, "fetch_message"): + result["status"] = ( + f"Channel {channel_id} cannot fetch messages (not a text channel)" + ) + await client.close() + return + + # Fetch the message to reply to + try: + message = await channel.fetch_message(int(message_id)) # type: ignore + except discord.errors.NotFound: + result["status"] = f"Message with ID {message_id} not found" + await client.close() + return + + # Send the reply + reply = await message.reply( + content=reply_content, mention_author=mention_author + ) + result["status"] = "Reply sent successfully" + result["reply_id"] = str(reply.id) + + except ValueError as e: + result["status"] = f"Invalid ID format: {str(e)}" + except Exception as e: + result["status"] = f"Error sending reply: {str(e)}" + finally: + await client.close() + + await client.start(token) + return result + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + result = await self.send_reply( + token=credentials.api_key.get_secret_value(), + channel_id=input_data.channel_id, + message_id=input_data.message_id, + reply_content=input_data.reply_content, + mention_author=input_data.mention_author, + ) + + yield "status", result.get("status", "Unknown error") + if "reply_id" in result: + yield "reply_id", result["reply_id"] + + except discord.errors.LoginFailure as login_err: + raise ValueError(f"Login error occurred: {login_err}") + except Exception as e: + raise ValueError(f"An error occurred: {e}") + + +class DiscordUserInfoBlock(Block): + class Input(BlockSchema): + credentials: DiscordCredentials = DiscordCredentialsField() + user_id: str = SchemaField( + description="The Discord user ID to get information about" + ) + + class Output(BlockSchema): + user_id: str = SchemaField( + description="The user's ID (passed through for chaining)" + ) + username: str = SchemaField(description="The user's username") + display_name: str = SchemaField(description="The user's display name") + discriminator: str = SchemaField( + description="The user's discriminator (if applicable)" + ) + avatar_url: str = SchemaField(description="URL to the user's avatar") + is_bot: bool = SchemaField(description="Whether the user is a bot") + created_at: str = SchemaField(description="When the account was created") + + def __init__(self): + super().__init__( + id="9aeed32a-6ebf-49b8-a0a3-e2e509d86120", + input_schema=DiscordUserInfoBlock.Input, + output_schema=DiscordUserInfoBlock.Output, + description="Gets information about a Discord user by their ID.", + categories={BlockCategory.SOCIAL}, + test_input={ + "user_id": "123456789012345678", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("user_id", "123456789012345678"), + ("username", "testuser"), + ("display_name", "Test User"), + ("discriminator", "0"), + ( + "avatar_url", + "https://cdn.discordapp.com/avatars/123456789012345678/avatar.png", + ), + ("is_bot", False), + ("created_at", "2020-01-01T00:00:00"), + ], + test_mock={ + "get_user_info": lambda token, user_id: { + "user_id": "123456789012345678", + "username": "testuser", + "display_name": "Test User", + "discriminator": "0", + "avatar_url": "https://cdn.discordapp.com/avatars/123456789012345678/avatar.png", + "is_bot": False, + "created_at": "2020-01-01T00:00:00", + } + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def get_user_info(self, token: str, user_id: str) -> dict: + intents = discord.Intents.default() + client = discord.Client(intents=intents) + + result = {} + + @client.event + async def on_ready(): + try: + user = await client.fetch_user(int(user_id)) + + result["user_id"] = str(user.id) # Pass through the user ID + result["username"] = user.name + result["display_name"] = user.display_name or user.name + result["discriminator"] = user.discriminator + result["avatar_url"] = ( + str(user.avatar.url) + if user.avatar + else str(user.default_avatar.url) + ) + result["is_bot"] = user.bot + result["created_at"] = user.created_at.isoformat() + + except discord.errors.NotFound: + result["error"] = f"User with ID {user_id} not found" + except ValueError: + result["error"] = f"Invalid user ID format: {user_id}" + except Exception as e: + result["error"] = f"Error fetching user info: {str(e)}" + finally: + await client.close() + + await client.start(token) + return result + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + result = await self.get_user_info( + token=credentials.api_key.get_secret_value(), user_id=input_data.user_id + ) + + if "error" in result: + raise ValueError(result["error"]) + + yield "user_id", result["user_id"] + yield "username", result["username"] + yield "display_name", result["display_name"] + yield "discriminator", result["discriminator"] + yield "avatar_url", result["avatar_url"] + yield "is_bot", result["is_bot"] + yield "created_at", result["created_at"] + + except discord.errors.LoginFailure as login_err: + raise ValueError(f"Login error occurred: {login_err}") + except Exception as e: + raise ValueError(f"An error occurred: {e}") + + +class DiscordChannelInfoBlock(Block): + class Input(BlockSchema): + credentials: DiscordCredentials = DiscordCredentialsField() + channel_identifier: str = SchemaField( + description="Channel name or channel ID to look up" + ) + server_name: str = SchemaField( + description="Server name (optional, helps narrow down search)", + advanced=True, + default="", + ) + + class Output(BlockSchema): + channel_id: str = SchemaField(description="The channel's ID") + channel_name: str = SchemaField(description="The channel's name") + server_id: str = SchemaField(description="The server's ID") + server_name: str = SchemaField(description="The server's name") + channel_type: str = SchemaField( + description="Type of channel (text, voice, etc)" + ) + + def __init__(self): + super().__init__( + id="592f815e-35c3-4fed-96cd-a69966b45c8f", + input_schema=DiscordChannelInfoBlock.Input, + output_schema=DiscordChannelInfoBlock.Output, + description="Resolves Discord channel names to IDs and vice versa.", + categories={BlockCategory.SOCIAL}, + test_input={ + "channel_identifier": "general", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("channel_id", "123456789012345678"), + ("channel_name", "general"), + ("server_id", "987654321098765432"), + ("server_name", "Test Server"), + ("channel_type", "text"), + ], + test_mock={ + "get_channel_info": lambda token, channel_identifier, server_name: { + "channel_id": "123456789012345678", + "channel_name": "general", + "server_id": "987654321098765432", + "server_name": "Test Server", + "channel_type": "text", + } + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def get_channel_info( + self, token: str, channel_identifier: str, server_name: str | None + ) -> dict: + intents = discord.Intents.default() + intents.guilds = True + client = discord.Client(intents=intents) + + result = {} + + @client.event + async def on_ready(): + # Try to parse as channel ID first + channel = None + try: + channel_id = int(channel_identifier) + channel = client.get_channel(channel_id) + if channel: + result["channel_id"] = str(channel.id) + # Private channels may not have a name attribute + result["channel_name"] = getattr(channel, "name", "Private Channel") + # Check if channel has guild (not private) + if hasattr(channel, "guild"): + guild = getattr(channel, "guild", None) + if guild: + result["server_id"] = str(guild.id) + result["server_name"] = guild.name + else: + result["server_id"] = "" + result["server_name"] = "Direct Message" + else: + result["server_id"] = "" + result["server_name"] = "Direct Message" + # Get channel type safely + result["channel_type"] = str(getattr(channel, "type", "unknown")) + await client.close() + return + except ValueError: + # Not an ID, treat as channel name + for guild in client.guilds: + if server_name and guild.name != server_name: + continue + for ch in guild.channels: + if ch.name == channel_identifier: + result["channel_id"] = str(ch.id) + result["channel_name"] = ch.name + result["server_id"] = str(guild.id) + result["server_name"] = guild.name + result["channel_type"] = str(ch.type) + await client.close() + return + + result["error"] = f"Channel not found: {channel_identifier}" + await client.close() + + await client.start(token) + return result + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + try: + result = await self.get_channel_info( + token=credentials.api_key.get_secret_value(), + channel_identifier=input_data.channel_identifier, + server_name=input_data.server_name or None, + ) + + if "error" in result: + raise ValueError(result["error"]) + + yield "channel_id", result["channel_id"] + yield "channel_name", result["channel_name"] + yield "server_id", result["server_id"] + yield "server_name", result["server_name"] + yield "channel_type", result["channel_type"] + + except discord.errors.LoginFailure as login_err: + raise ValueError(f"Login error occurred: {login_err}") + except Exception as e: + raise ValueError(f"An error occurred: {e}") diff --git a/autogpt_platform/backend/backend/blocks/discord/oauth_blocks.py b/autogpt_platform/backend/backend/blocks/discord/oauth_blocks.py new file mode 100644 index 000000000000..31d2df65c26f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/discord/oauth_blocks.py @@ -0,0 +1,99 @@ +""" +Discord OAuth-based blocks. +""" + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import OAuth2Credentials, SchemaField + +from ._api import DiscordOAuthUser, get_current_user +from ._auth import ( + DISCORD_OAUTH_IS_CONFIGURED, + TEST_OAUTH_CREDENTIALS, + TEST_OAUTH_CREDENTIALS_INPUT, + DiscordOAuthCredentialsField, + DiscordOAuthCredentialsInput, +) + + +class DiscordGetCurrentUserBlock(Block): + """ + Gets information about the currently authenticated Discord user using OAuth2. + This block requires Discord OAuth2 credentials (not bot tokens). + """ + + class Input(BlockSchema): + credentials: DiscordOAuthCredentialsInput = DiscordOAuthCredentialsField( + ["identify"] + ) + + class Output(BlockSchema): + user_id: str = SchemaField(description="The authenticated user's Discord ID") + username: str = SchemaField(description="The user's username") + avatar_url: str = SchemaField(description="URL to the user's avatar image") + banner_url: str = SchemaField( + description="URL to the user's banner image (if set)", default="" + ) + accent_color: int = SchemaField( + description="The user's accent color as an integer", default=0 + ) + + def __init__(self): + super().__init__( + id="8c7e39b8-4e9d-4f3a-b4e1-2a8c9d5f6e3b", + input_schema=DiscordGetCurrentUserBlock.Input, + output_schema=DiscordGetCurrentUserBlock.Output, + description="Gets information about the currently authenticated Discord user using OAuth2 credentials.", + categories={BlockCategory.SOCIAL}, + disabled=not DISCORD_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_OAUTH_CREDENTIALS_INPUT, + }, + test_credentials=TEST_OAUTH_CREDENTIALS, + test_output=[ + ("user_id", "123456789012345678"), + ("username", "testuser"), + ( + "avatar_url", + "https://cdn.discordapp.com/avatars/123456789012345678/avatar.png", + ), + ("banner_url", ""), + ("accent_color", 0), + ], + test_mock={ + "get_user": lambda _: DiscordOAuthUser( + user_id="123456789012345678", + username="testuser", + avatar_url="https://cdn.discordapp.com/avatars/123456789012345678/avatar.png", + banner=None, + accent_color=0, + ) + }, + ) + + @staticmethod + async def get_user(credentials: OAuth2Credentials) -> DiscordOAuthUser: + user_info = await get_current_user(credentials) + return user_info + + async def run( + self, input_data: Input, *, credentials: OAuth2Credentials, **kwargs + ) -> BlockOutput: + try: + result = await self.get_user(credentials) + + # Yield each output field + yield "user_id", result.user_id + yield "username", result.username + yield "avatar_url", result.avatar_url + + # Handle banner URL if banner hash exists + if result.banner: + banner_url = f"https://cdn.discordapp.com/banners/{result.user_id}/{result.banner}.png" + yield "banner_url", banner_url + else: + yield "banner_url", "" + + yield "accent_color", result.accent_color or 0 + + except Exception as e: + raise ValueError(f"Failed to get Discord user info: {e}") diff --git a/autogpt_platform/backend/backend/blocks/email_block.py b/autogpt_platform/backend/backend/blocks/email_block.py index dd63dbbcf6df..3738bf0de836 100644 --- a/autogpt_platform/backend/backend/blocks/email_block.py +++ b/autogpt_platform/backend/backend/blocks/email_block.py @@ -1,22 +1,53 @@ import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from typing import Literal -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, SecretStr from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema -from backend.data.model import BlockSecret, SchemaField, SecretField +from backend.data.model import ( + CredentialsField, + CredentialsMetaInput, + SchemaField, + UserPasswordCredentials, +) +from backend.integrations.providers import ProviderName +TEST_CREDENTIALS = UserPasswordCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="smtp", + username=SecretStr("mock-smtp-username"), + password=SecretStr("mock-smtp-password"), + title="Mock SMTP credentials", +) -class EmailCredentials(BaseModel): +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} +SMTPCredentials = UserPasswordCredentials +SMTPCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.SMTP], + Literal["user_password"], +] + + +def SMTPCredentialsField() -> SMTPCredentialsInput: + return CredentialsField( + description="The SMTP integration requires a username and password.", + ) + + +class SMTPConfig(BaseModel): smtp_server: str = SchemaField( - default="smtp.gmail.com", description="SMTP server address" + default="smtp.example.com", description="SMTP server address" ) smtp_port: int = SchemaField(default=25, description="SMTP port number") - smtp_username: BlockSecret = SecretField(key="smtp_username") - smtp_password: BlockSecret = SecretField(key="smtp_password") - model_config = ConfigDict(title="Email Credentials") + model_config = ConfigDict(title="SMTP Config") class SendEmailBlock(Block): @@ -30,10 +61,11 @@ class Input(BlockSchema): body: str = SchemaField( description="Body of the email", placeholder="Enter the email body" ) - creds: EmailCredentials = SchemaField( - description="SMTP credentials", - default=EmailCredentials(), + config: SMTPConfig = SchemaField( + description="SMTP Config", + default=SMTPConfig(), ) + credentials: SMTPCredentialsInput = SMTPCredentialsField() class Output(BlockSchema): status: str = SchemaField(description="Status of the email sending operation") @@ -43,7 +75,6 @@ class Output(BlockSchema): def __init__(self): super().__init__( - disabled=True, id="4335878a-394e-4e67-adf2-919877ff49ae", description="This block sends an email using the provided SMTP credentials.", categories={BlockCategory.OUTPUT}, @@ -53,25 +84,29 @@ def __init__(self): "to_email": "recipient@example.com", "subject": "Test Email", "body": "This is a test email.", - "creds": { + "config": { "smtp_server": "smtp.gmail.com", "smtp_port": 25, - "smtp_username": "your-email@gmail.com", - "smtp_password": "your-gmail-password", }, + "credentials": TEST_CREDENTIALS_INPUT, }, + test_credentials=TEST_CREDENTIALS, test_output=[("status", "Email sent successfully")], test_mock={"send_email": lambda *args, **kwargs: "Email sent successfully"}, ) @staticmethod def send_email( - creds: EmailCredentials, to_email: str, subject: str, body: str + config: SMTPConfig, + to_email: str, + subject: str, + body: str, + credentials: SMTPCredentials, ) -> str: - smtp_server = creds.smtp_server - smtp_port = creds.smtp_port - smtp_username = creds.smtp_username.get_secret_value() - smtp_password = creds.smtp_password.get_secret_value() + smtp_server = config.smtp_server + smtp_port = config.smtp_port + smtp_username = credentials.username.get_secret_value() + smtp_password = credentials.password.get_secret_value() msg = MIMEMultipart() msg["From"] = smtp_username @@ -86,10 +121,13 @@ def send_email( return "Email sent successfully" - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run( + self, input_data: Input, *, credentials: SMTPCredentials, **kwargs + ) -> BlockOutput: yield "status", self.send_email( - input_data.creds, - input_data.to_email, - input_data.subject, - input_data.body, + config=input_data.config, + to_email=input_data.to_email, + subject=input_data.subject, + body=input_data.body, + credentials=credentials, ) diff --git a/autogpt_platform/backend/backend/blocks/enrichlayer/_api.py b/autogpt_platform/backend/backend/blocks/enrichlayer/_api.py new file mode 100644 index 000000000000..7d1f0e61f06c --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/enrichlayer/_api.py @@ -0,0 +1,408 @@ +""" +API module for Enrichlayer integration. + +This module provides a client for interacting with the Enrichlayer API, +which allows fetching LinkedIn profile data and related information. +""" + +import datetime +import enum +import logging +from json import JSONDecodeError +from typing import Any, Optional, TypeVar + +from pydantic import BaseModel, Field + +from backend.data.model import APIKeyCredentials +from backend.util.request import Requests + +logger = logging.getLogger(__name__) + +T = TypeVar("T") + + +class EnrichlayerAPIException(Exception): + """Exception raised for Enrichlayer API errors.""" + + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.status_code = status_code + + +class FallbackToCache(enum.Enum): + ON_ERROR = "on-error" + NEVER = "never" + + +class UseCache(enum.Enum): + IF_PRESENT = "if-present" + NEVER = "never" + + +class SocialMediaProfiles(BaseModel): + """Social media profiles model.""" + + twitter: Optional[str] = None + facebook: Optional[str] = None + github: Optional[str] = None + + +class Experience(BaseModel): + """Experience model for LinkedIn profiles.""" + + company: Optional[str] = None + title: Optional[str] = None + description: Optional[str] = None + location: Optional[str] = None + starts_at: Optional[dict[str, int]] = None + ends_at: Optional[dict[str, int]] = None + company_linkedin_profile_url: Optional[str] = None + + +class Education(BaseModel): + """Education model for LinkedIn profiles.""" + + school: Optional[str] = None + degree_name: Optional[str] = None + field_of_study: Optional[str] = None + starts_at: Optional[dict[str, int]] = None + ends_at: Optional[dict[str, int]] = None + school_linkedin_profile_url: Optional[str] = None + + +class PersonProfileResponse(BaseModel): + """Response model for LinkedIn person profile. + + This model represents the response from Enrichlayer's LinkedIn profile API. + The API returns comprehensive profile data including work experience, + education, skills, and contact information (when available). + + Example API Response: + { + "public_identifier": "johnsmith", + "full_name": "John Smith", + "occupation": "Software Engineer at Tech Corp", + "experiences": [ + { + "company": "Tech Corp", + "title": "Software Engineer", + "starts_at": {"year": 2020, "month": 1} + } + ], + "education": [...], + "skills": ["Python", "JavaScript", ...] + } + """ + + public_identifier: Optional[str] = None + profile_pic_url: Optional[str] = None + full_name: Optional[str] = None + first_name: Optional[str] = None + last_name: Optional[str] = None + occupation: Optional[str] = None + headline: Optional[str] = None + summary: Optional[str] = None + country: Optional[str] = None + country_full_name: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + experiences: Optional[list[Experience]] = None + education: Optional[list[Education]] = None + languages: Optional[list[str]] = None + skills: Optional[list[str]] = None + inferred_salary: Optional[dict[str, Any]] = None + personal_email: Optional[str] = None + personal_contact_number: Optional[str] = None + social_media_profiles: Optional[SocialMediaProfiles] = None + extra: Optional[dict[str, Any]] = None + + +class SimilarProfile(BaseModel): + """Similar profile model for LinkedIn person lookup.""" + + similarity: float + linkedin_profile_url: str + + +class PersonLookupResponse(BaseModel): + """Response model for LinkedIn person lookup. + + This model represents the response from Enrichlayer's person lookup API. + The API returns a LinkedIn profile URL and similarity scores when + searching for a person by name and company. + + Example API Response: + { + "url": "https://www.linkedin.com/in/johnsmith/", + "name_similarity_score": 0.95, + "company_similarity_score": 0.88, + "title_similarity_score": 0.75, + "location_similarity_score": 0.60 + } + """ + + url: str | None = None + name_similarity_score: float | None + company_similarity_score: float | None + title_similarity_score: float | None + location_similarity_score: float | None + last_updated: datetime.datetime | None = None + profile: PersonProfileResponse | None = None + + +class RoleLookupResponse(BaseModel): + """Response model for LinkedIn role lookup. + + This model represents the response from Enrichlayer's role lookup API. + The API returns LinkedIn profile data for a specific role at a company. + + Example API Response: + { + "linkedin_profile_url": "https://www.linkedin.com/in/johnsmith/", + "profile_data": {...} // Full PersonProfileResponse data when enrich_profile=True + } + """ + + linkedin_profile_url: Optional[str] = None + profile_data: Optional[PersonProfileResponse] = None + + +class ProfilePictureResponse(BaseModel): + """Response model for LinkedIn profile picture. + + This model represents the response from Enrichlayer's profile picture API. + The API returns a URL to the person's LinkedIn profile picture. + + Example API Response: + { + "tmp_profile_pic_url": "https://media.licdn.com/dms/image/..." + } + """ + + tmp_profile_pic_url: str = Field( + ..., description="URL of the profile picture", alias="tmp_profile_pic_url" + ) + + @property + def profile_picture_url(self) -> str: + """Backward compatibility property for profile_picture_url.""" + return self.tmp_profile_pic_url + + +class EnrichlayerClient: + """Client for interacting with the Enrichlayer API.""" + + API_BASE_URL = "https://enrichlayer.com/api/v2" + + def __init__( + self, + credentials: Optional[APIKeyCredentials] = None, + custom_requests: Optional[Requests] = None, + ): + """ + Initialize the Enrichlayer client. + + Args: + credentials: The credentials to use for authentication. + custom_requests: Custom Requests instance for testing. + """ + if custom_requests: + self._requests = custom_requests + else: + headers: dict[str, str] = { + "Content-Type": "application/json", + } + if credentials: + headers["Authorization"] = ( + f"Bearer {credentials.api_key.get_secret_value()}" + ) + + self._requests = Requests( + extra_headers=headers, + raise_for_status=False, + ) + + async def _handle_response(self, response) -> Any: + """ + Handle API response and check for errors. + + Args: + response: The response object from the request. + + Returns: + The response data. + + Raises: + EnrichlayerAPIException: If the API request fails. + """ + if not response.ok: + try: + error_data = response.json() + error_message = error_data.get("message", "") + except JSONDecodeError: + error_message = response.text + + raise EnrichlayerAPIException( + f"Enrichlayer API request failed ({response.status_code}): {error_message}", + response.status_code, + ) + + return response.json() + + async def fetch_profile( + self, + linkedin_url: str, + fallback_to_cache: FallbackToCache = FallbackToCache.ON_ERROR, + use_cache: UseCache = UseCache.IF_PRESENT, + include_skills: bool = False, + include_inferred_salary: bool = False, + include_personal_email: bool = False, + include_personal_contact_number: bool = False, + include_social_media: bool = False, + include_extra: bool = False, + ) -> PersonProfileResponse: + """ + Fetch a LinkedIn profile with optional parameters. + + Args: + linkedin_url: The LinkedIn profile URL to fetch. + fallback_to_cache: Cache usage if live fetch fails ('on-error' or 'never'). + use_cache: Cache utilization ('if-present' or 'never'). + include_skills: Whether to include skills data. + include_inferred_salary: Whether to include inferred salary data. + include_personal_email: Whether to include personal email. + include_personal_contact_number: Whether to include personal contact number. + include_social_media: Whether to include social media profiles. + include_extra: Whether to include additional data. + + Returns: + The LinkedIn profile data. + + Raises: + EnrichlayerAPIException: If the API request fails. + """ + params = { + "url": linkedin_url, + "fallback_to_cache": fallback_to_cache.value.lower(), + "use_cache": use_cache.value.lower(), + } + + if include_skills: + params["skills"] = "include" + if include_inferred_salary: + params["inferred_salary"] = "include" + if include_personal_email: + params["personal_email"] = "include" + if include_personal_contact_number: + params["personal_contact_number"] = "include" + if include_social_media: + params["twitter_profile_id"] = "include" + params["facebook_profile_id"] = "include" + params["github_profile_id"] = "include" + if include_extra: + params["extra"] = "include" + + response = await self._requests.get( + f"{self.API_BASE_URL}/profile", params=params + ) + return PersonProfileResponse(**await self._handle_response(response)) + + async def lookup_person( + self, + first_name: str, + company_domain: str, + last_name: str | None = None, + location: Optional[str] = None, + title: Optional[str] = None, + include_similarity_checks: bool = False, + enrich_profile: bool = False, + ) -> PersonLookupResponse: + """ + Look up a LinkedIn profile by person's information. + + Args: + first_name: The person's first name. + last_name: The person's last name. + company_domain: The domain of the company they work for. + location: The person's location. + title: The person's job title. + include_similarity_checks: Whether to include similarity checks. + enrich_profile: Whether to enrich the profile. + + Returns: + The LinkedIn profile lookup result. + + Raises: + EnrichlayerAPIException: If the API request fails. + """ + params = {"first_name": first_name, "company_domain": company_domain} + + if last_name: + params["last_name"] = last_name + if location: + params["location"] = location + if title: + params["title"] = title + if include_similarity_checks: + params["similarity_checks"] = "include" + if enrich_profile: + params["enrich_profile"] = "enrich" + + response = await self._requests.get( + f"{self.API_BASE_URL}/profile/resolve", params=params + ) + return PersonLookupResponse(**await self._handle_response(response)) + + async def lookup_role( + self, role: str, company_name: str, enrich_profile: bool = False + ) -> RoleLookupResponse: + """ + Look up a LinkedIn profile by role in a company. + + Args: + role: The role title (e.g., CEO, CTO). + company_name: The name of the company. + enrich_profile: Whether to enrich the profile. + + Returns: + The LinkedIn profile lookup result. + + Raises: + EnrichlayerAPIException: If the API request fails. + """ + params = { + "role": role, + "company_name": company_name, + } + + if enrich_profile: + params["enrich_profile"] = "enrich" + + response = await self._requests.get( + f"{self.API_BASE_URL}/find/company/role", params=params + ) + return RoleLookupResponse(**await self._handle_response(response)) + + async def get_profile_picture( + self, linkedin_profile_url: str + ) -> ProfilePictureResponse: + """ + Get a LinkedIn profile picture URL. + + Args: + linkedin_profile_url: The LinkedIn profile URL. + + Returns: + The profile picture URL. + + Raises: + EnrichlayerAPIException: If the API request fails. + """ + params = { + "linkedin_person_profile_url": linkedin_profile_url, + } + + response = await self._requests.get( + f"{self.API_BASE_URL}/person/profile-picture", params=params + ) + return ProfilePictureResponse(**await self._handle_response(response)) diff --git a/autogpt_platform/backend/backend/blocks/enrichlayer/_auth.py b/autogpt_platform/backend/backend/blocks/enrichlayer/_auth.py new file mode 100644 index 000000000000..70413adba4ad --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/enrichlayer/_auth.py @@ -0,0 +1,34 @@ +""" +Authentication module for Enrichlayer API integration. + +This module provides credential types and test credentials for the Enrichlayer API. +""" + +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import APIKeyCredentials, CredentialsMetaInput +from backend.integrations.providers import ProviderName + +# Define the type of credentials input expected for Enrichlayer API +EnrichlayerCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.ENRICHLAYER], Literal["api_key"] +] + +# Mock credentials for testing Enrichlayer API integration +TEST_CREDENTIALS = APIKeyCredentials( + id="1234a567-89bc-4def-ab12-3456cdef7890", + provider="enrichlayer", + api_key=SecretStr("mock-enrichlayer-api-key"), + title="Mock Enrichlayer API key", + expires_at=None, +) + +# Dictionary representation of test credentials for input fields +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} diff --git a/autogpt_platform/backend/backend/blocks/enrichlayer/linkedin.py b/autogpt_platform/backend/backend/blocks/enrichlayer/linkedin.py new file mode 100644 index 000000000000..52d593eb0ecc --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/enrichlayer/linkedin.py @@ -0,0 +1,527 @@ +""" +Block definitions for Enrichlayer API integration. + +This module implements blocks for interacting with the Enrichlayer API, +which provides access to LinkedIn profile data and related information. +""" + +import logging +from typing import Optional + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import APIKeyCredentials, CredentialsField, SchemaField +from backend.util.type import MediaFileType + +from ._api import ( + EnrichlayerClient, + Experience, + FallbackToCache, + PersonLookupResponse, + PersonProfileResponse, + RoleLookupResponse, + UseCache, +) +from ._auth import TEST_CREDENTIALS, TEST_CREDENTIALS_INPUT, EnrichlayerCredentialsInput + +logger = logging.getLogger(__name__) + + +class GetLinkedinProfileBlock(Block): + """Block to fetch LinkedIn profile data using Enrichlayer API.""" + + class Input(BlockSchema): + """Input schema for GetLinkedinProfileBlock.""" + + linkedin_url: str = SchemaField( + description="LinkedIn profile URL to fetch data from", + placeholder="https://www.linkedin.com/in/username/", + ) + fallback_to_cache: FallbackToCache = SchemaField( + description="Cache usage if live fetch fails", + default=FallbackToCache.ON_ERROR, + advanced=True, + ) + use_cache: UseCache = SchemaField( + description="Cache utilization strategy", + default=UseCache.IF_PRESENT, + advanced=True, + ) + include_skills: bool = SchemaField( + description="Include skills data", + default=False, + advanced=True, + ) + include_inferred_salary: bool = SchemaField( + description="Include inferred salary data", + default=False, + advanced=True, + ) + include_personal_email: bool = SchemaField( + description="Include personal email", + default=False, + advanced=True, + ) + include_personal_contact_number: bool = SchemaField( + description="Include personal contact number", + default=False, + advanced=True, + ) + include_social_media: bool = SchemaField( + description="Include social media profiles", + default=False, + advanced=True, + ) + include_extra: bool = SchemaField( + description="Include additional data", + default=False, + advanced=True, + ) + credentials: EnrichlayerCredentialsInput = CredentialsField( + description="Enrichlayer API credentials" + ) + + class Output(BlockSchema): + """Output schema for GetLinkedinProfileBlock.""" + + profile: PersonProfileResponse = SchemaField( + description="LinkedIn profile data" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + """Initialize GetLinkedinProfileBlock.""" + super().__init__( + id="f6e0ac73-4f1d-4acb-b4b7-b67066c5984e", + description="Fetch LinkedIn profile data using Enrichlayer", + categories={BlockCategory.SOCIAL}, + input_schema=GetLinkedinProfileBlock.Input, + output_schema=GetLinkedinProfileBlock.Output, + test_input={ + "linkedin_url": "https://www.linkedin.com/in/williamhgates/", + "include_skills": True, + "include_social_media": True, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ( + "profile", + PersonProfileResponse( + public_identifier="williamhgates", + full_name="Bill Gates", + occupation="Co-chair at Bill & Melinda Gates Foundation", + experiences=[ + Experience( + company="Bill & Melinda Gates Foundation", + title="Co-chair", + starts_at={"year": 2000}, + ) + ], + ), + ) + ], + test_credentials=TEST_CREDENTIALS, + test_mock={ + "_fetch_profile": lambda *args, **kwargs: PersonProfileResponse( + public_identifier="williamhgates", + full_name="Bill Gates", + occupation="Co-chair at Bill & Melinda Gates Foundation", + experiences=[ + Experience( + company="Bill & Melinda Gates Foundation", + title="Co-chair", + starts_at={"year": 2000}, + ) + ], + ), + }, + ) + + @staticmethod + async def _fetch_profile( + credentials: APIKeyCredentials, + linkedin_url: str, + fallback_to_cache: FallbackToCache = FallbackToCache.ON_ERROR, + use_cache: UseCache = UseCache.IF_PRESENT, + include_skills: bool = False, + include_inferred_salary: bool = False, + include_personal_email: bool = False, + include_personal_contact_number: bool = False, + include_social_media: bool = False, + include_extra: bool = False, + ): + client = EnrichlayerClient(credentials) + profile = await client.fetch_profile( + linkedin_url=linkedin_url, + fallback_to_cache=fallback_to_cache, + use_cache=use_cache, + include_skills=include_skills, + include_inferred_salary=include_inferred_salary, + include_personal_email=include_personal_email, + include_personal_contact_number=include_personal_contact_number, + include_social_media=include_social_media, + include_extra=include_extra, + ) + return profile + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + """ + Run the block to fetch LinkedIn profile data. + + Args: + input_data: Input parameters for the block + credentials: API key credentials for Enrichlayer + **kwargs: Additional keyword arguments + + Yields: + Tuples of (output_name, output_value) + """ + try: + profile = await self._fetch_profile( + credentials=credentials, + linkedin_url=input_data.linkedin_url, + fallback_to_cache=input_data.fallback_to_cache, + use_cache=input_data.use_cache, + include_skills=input_data.include_skills, + include_inferred_salary=input_data.include_inferred_salary, + include_personal_email=input_data.include_personal_email, + include_personal_contact_number=input_data.include_personal_contact_number, + include_social_media=input_data.include_social_media, + include_extra=input_data.include_extra, + ) + yield "profile", profile + except Exception as e: + logger.error(f"Error fetching LinkedIn profile: {str(e)}") + yield "error", str(e) + + +class LinkedinPersonLookupBlock(Block): + """Block to look up LinkedIn profiles by person's information using Enrichlayer API.""" + + class Input(BlockSchema): + """Input schema for LinkedinPersonLookupBlock.""" + + first_name: str = SchemaField( + description="Person's first name", + placeholder="John", + advanced=False, + ) + last_name: str | None = SchemaField( + description="Person's last name", + placeholder="Doe", + default=None, + advanced=False, + ) + company_domain: str = SchemaField( + description="Domain of the company they work for (optional)", + placeholder="example.com", + advanced=False, + ) + location: Optional[str] = SchemaField( + description="Person's location (optional)", + placeholder="San Francisco", + default=None, + ) + title: Optional[str] = SchemaField( + description="Person's job title (optional)", + placeholder="CEO", + default=None, + ) + include_similarity_checks: bool = SchemaField( + description="Include similarity checks", + default=False, + advanced=True, + ) + enrich_profile: bool = SchemaField( + description="Enrich the profile with additional data", + default=False, + advanced=True, + ) + credentials: EnrichlayerCredentialsInput = CredentialsField( + description="Enrichlayer API credentials" + ) + + class Output(BlockSchema): + """Output schema for LinkedinPersonLookupBlock.""" + + lookup_result: PersonLookupResponse = SchemaField( + description="LinkedIn profile lookup result" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + """Initialize LinkedinPersonLookupBlock.""" + super().__init__( + id="d237a98a-5c4b-4a1c-b9e3-e6f9a6c81df7", + description="Look up LinkedIn profiles by person information using Enrichlayer", + categories={BlockCategory.SOCIAL}, + input_schema=LinkedinPersonLookupBlock.Input, + output_schema=LinkedinPersonLookupBlock.Output, + test_input={ + "first_name": "Bill", + "last_name": "Gates", + "company_domain": "gatesfoundation.org", + "include_similarity_checks": True, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ( + "lookup_result", + PersonLookupResponse( + url="https://www.linkedin.com/in/williamhgates/", + name_similarity_score=0.93, + company_similarity_score=0.83, + title_similarity_score=0.3, + location_similarity_score=0.20, + ), + ) + ], + test_credentials=TEST_CREDENTIALS, + test_mock={ + "_lookup_person": lambda *args, **kwargs: PersonLookupResponse( + url="https://www.linkedin.com/in/williamhgates/", + name_similarity_score=0.93, + company_similarity_score=0.83, + title_similarity_score=0.3, + location_similarity_score=0.20, + ) + }, + ) + + @staticmethod + async def _lookup_person( + credentials: APIKeyCredentials, + first_name: str, + company_domain: str, + last_name: str | None = None, + location: Optional[str] = None, + title: Optional[str] = None, + include_similarity_checks: bool = False, + enrich_profile: bool = False, + ): + client = EnrichlayerClient(credentials=credentials) + lookup_result = await client.lookup_person( + first_name=first_name, + last_name=last_name, + company_domain=company_domain, + location=location, + title=title, + include_similarity_checks=include_similarity_checks, + enrich_profile=enrich_profile, + ) + return lookup_result + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + """ + Run the block to look up LinkedIn profiles. + + Args: + input_data: Input parameters for the block + credentials: API key credentials for Enrichlayer + **kwargs: Additional keyword arguments + + Yields: + Tuples of (output_name, output_value) + """ + try: + lookup_result = await self._lookup_person( + credentials=credentials, + first_name=input_data.first_name, + last_name=input_data.last_name, + company_domain=input_data.company_domain, + location=input_data.location, + title=input_data.title, + include_similarity_checks=input_data.include_similarity_checks, + enrich_profile=input_data.enrich_profile, + ) + yield "lookup_result", lookup_result + except Exception as e: + logger.error(f"Error looking up LinkedIn profile: {str(e)}") + yield "error", str(e) + + +class LinkedinRoleLookupBlock(Block): + """Block to look up LinkedIn profiles by role in a company using Enrichlayer API.""" + + class Input(BlockSchema): + """Input schema for LinkedinRoleLookupBlock.""" + + role: str = SchemaField( + description="Role title (e.g., CEO, CTO)", + placeholder="CEO", + ) + company_name: str = SchemaField( + description="Name of the company", + placeholder="Microsoft", + ) + enrich_profile: bool = SchemaField( + description="Enrich the profile with additional data", + default=False, + advanced=True, + ) + credentials: EnrichlayerCredentialsInput = CredentialsField( + description="Enrichlayer API credentials" + ) + + class Output(BlockSchema): + """Output schema for LinkedinRoleLookupBlock.""" + + role_lookup_result: RoleLookupResponse = SchemaField( + description="LinkedIn role lookup result" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + """Initialize LinkedinRoleLookupBlock.""" + super().__init__( + id="3b9fc742-06d4-49c7-b5ce-7e302dd7c8a7", + description="Look up LinkedIn profiles by role in a company using Enrichlayer", + categories={BlockCategory.SOCIAL}, + input_schema=LinkedinRoleLookupBlock.Input, + output_schema=LinkedinRoleLookupBlock.Output, + test_input={ + "role": "Co-chair", + "company_name": "Gates Foundation", + "enrich_profile": True, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ( + "role_lookup_result", + RoleLookupResponse( + linkedin_profile_url="https://www.linkedin.com/in/williamhgates/", + ), + ) + ], + test_credentials=TEST_CREDENTIALS, + test_mock={ + "_lookup_role": lambda *args, **kwargs: RoleLookupResponse( + linkedin_profile_url="https://www.linkedin.com/in/williamhgates/", + ), + }, + ) + + @staticmethod + async def _lookup_role( + credentials: APIKeyCredentials, + role: str, + company_name: str, + enrich_profile: bool = False, + ): + client = EnrichlayerClient(credentials=credentials) + role_lookup_result = await client.lookup_role( + role=role, + company_name=company_name, + enrich_profile=enrich_profile, + ) + return role_lookup_result + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + """ + Run the block to look up LinkedIn profiles by role. + + Args: + input_data: Input parameters for the block + credentials: API key credentials for Enrichlayer + **kwargs: Additional keyword arguments + + Yields: + Tuples of (output_name, output_value) + """ + try: + role_lookup_result = await self._lookup_role( + credentials=credentials, + role=input_data.role, + company_name=input_data.company_name, + enrich_profile=input_data.enrich_profile, + ) + yield "role_lookup_result", role_lookup_result + except Exception as e: + logger.error(f"Error looking up role in company: {str(e)}") + yield "error", str(e) + + +class GetLinkedinProfilePictureBlock(Block): + """Block to get LinkedIn profile pictures using Enrichlayer API.""" + + class Input(BlockSchema): + """Input schema for GetLinkedinProfilePictureBlock.""" + + linkedin_profile_url: str = SchemaField( + description="LinkedIn profile URL", + placeholder="https://www.linkedin.com/in/username/", + ) + credentials: EnrichlayerCredentialsInput = CredentialsField( + description="Enrichlayer API credentials" + ) + + class Output(BlockSchema): + """Output schema for GetLinkedinProfilePictureBlock.""" + + profile_picture_url: MediaFileType = SchemaField( + description="LinkedIn profile picture URL" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + """Initialize GetLinkedinProfilePictureBlock.""" + super().__init__( + id="68d5a942-9b3f-4e9a-b7c1-d96ea4321f0d", + description="Get LinkedIn profile pictures using Enrichlayer", + categories={BlockCategory.SOCIAL}, + input_schema=GetLinkedinProfilePictureBlock.Input, + output_schema=GetLinkedinProfilePictureBlock.Output, + test_input={ + "linkedin_profile_url": "https://www.linkedin.com/in/williamhgates/", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ( + "profile_picture_url", + "https://media.licdn.com/dms/image/C4D03AQFj-xjuXrLFSQ/profile-displayphoto-shrink_800_800/0/1576881858598?e=1686787200&v=beta&t=zrQC76QwsfQQIWthfOnrKRBMZ5D-qIAvzLXLmWgYvTk", + ) + ], + test_credentials=TEST_CREDENTIALS, + test_mock={ + "_get_profile_picture": lambda *args, **kwargs: "https://media.licdn.com/dms/image/C4D03AQFj-xjuXrLFSQ/profile-displayphoto-shrink_800_800/0/1576881858598?e=1686787200&v=beta&t=zrQC76QwsfQQIWthfOnrKRBMZ5D-qIAvzLXLmWgYvTk", + }, + ) + + @staticmethod + async def _get_profile_picture( + credentials: APIKeyCredentials, linkedin_profile_url: str + ): + client = EnrichlayerClient(credentials=credentials) + profile_picture_response = await client.get_profile_picture( + linkedin_profile_url=linkedin_profile_url, + ) + return profile_picture_response.profile_picture_url + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + """ + Run the block to get LinkedIn profile pictures. + + Args: + input_data: Input parameters for the block + credentials: API key credentials for Enrichlayer + **kwargs: Additional keyword arguments + + Yields: + Tuples of (output_name, output_value) + """ + try: + profile_picture = await self._get_profile_picture( + credentials=credentials, + linkedin_profile_url=input_data.linkedin_profile_url, + ) + yield "profile_picture_url", profile_picture + except Exception as e: + logger.error(f"Error getting profile picture: {str(e)}") + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/exa/_auth.py b/autogpt_platform/backend/backend/blocks/exa/_auth.py deleted file mode 100644 index 7b826ef408b2..000000000000 --- a/autogpt_platform/backend/backend/blocks/exa/_auth.py +++ /dev/null @@ -1,32 +0,0 @@ -from typing import Literal - -from pydantic import SecretStr - -from backend.data.model import APIKeyCredentials, CredentialsField, CredentialsMetaInput -from backend.integrations.providers import ProviderName - -ExaCredentials = APIKeyCredentials -ExaCredentialsInput = CredentialsMetaInput[ - Literal[ProviderName.EXA], - Literal["api_key"], -] - -TEST_CREDENTIALS = APIKeyCredentials( - id="01234567-89ab-cdef-0123-456789abcdef", - provider="exa", - api_key=SecretStr("mock-exa-api-key"), - title="Mock Exa API key", - expires_at=None, -) - -TEST_CREDENTIALS_INPUT = { - "provider": TEST_CREDENTIALS.provider, - "id": TEST_CREDENTIALS.id, - "type": TEST_CREDENTIALS.type, - "title": TEST_CREDENTIALS.title, -} - - -def ExaCredentialsField() -> ExaCredentialsInput: - """Creates an Exa credentials input on a block.""" - return CredentialsField(description="The Exa integration requires an API Key.") diff --git a/autogpt_platform/backend/backend/blocks/exa/_config.py b/autogpt_platform/backend/backend/blocks/exa/_config.py new file mode 100644 index 000000000000..bca636b2a8af --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/exa/_config.py @@ -0,0 +1,16 @@ +""" +Shared configuration for all Exa blocks using the new SDK pattern. +""" + +from backend.sdk import BlockCostType, ProviderBuilder + +from ._webhook import ExaWebhookManager + +# Configure the Exa provider once for all blocks +exa = ( + ProviderBuilder("exa") + .with_api_key("EXA_API_KEY", "Exa API Key") + .with_webhook_manager(ExaWebhookManager) + .with_base_cost(1, BlockCostType.RUN) + .build() +) diff --git a/autogpt_platform/backend/backend/blocks/exa/_webhook.py b/autogpt_platform/backend/backend/blocks/exa/_webhook.py new file mode 100644 index 000000000000..725976c68c79 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/exa/_webhook.py @@ -0,0 +1,136 @@ +""" +Exa Webhook Manager implementation. +""" + +import hashlib +import hmac +from enum import Enum + +from backend.data.model import Credentials +from backend.sdk import ( + APIKeyCredentials, + BaseWebhooksManager, + ProviderName, + Requests, + Webhook, +) + + +class ExaWebhookType(str, Enum): + """Available webhook types for Exa.""" + + WEBSET = "webset" + + +class ExaEventType(str, Enum): + """Available event types for Exa webhooks.""" + + WEBSET_CREATED = "webset.created" + WEBSET_DELETED = "webset.deleted" + WEBSET_PAUSED = "webset.paused" + WEBSET_IDLE = "webset.idle" + WEBSET_SEARCH_CREATED = "webset.search.created" + WEBSET_SEARCH_CANCELED = "webset.search.canceled" + WEBSET_SEARCH_COMPLETED = "webset.search.completed" + WEBSET_SEARCH_UPDATED = "webset.search.updated" + IMPORT_CREATED = "import.created" + IMPORT_COMPLETED = "import.completed" + IMPORT_PROCESSING = "import.processing" + WEBSET_ITEM_CREATED = "webset.item.created" + WEBSET_ITEM_ENRICHED = "webset.item.enriched" + WEBSET_EXPORT_CREATED = "webset.export.created" + WEBSET_EXPORT_COMPLETED = "webset.export.completed" + + +class ExaWebhookManager(BaseWebhooksManager): + """Webhook manager for Exa API.""" + + PROVIDER_NAME = ProviderName("exa") + + class WebhookType(str, Enum): + WEBSET = "webset" + + @classmethod + async def validate_payload( + cls, webhook: Webhook, request, credentials: Credentials | None + ) -> tuple[dict, str]: + """Validate incoming webhook payload and signature.""" + payload = await request.json() + + # Get event type from payload + event_type = payload.get("eventType", "unknown") + + # Verify webhook signature if secret is available + if webhook.secret: + signature = request.headers.get("X-Exa-Signature") + if signature: + # Compute expected signature + body = await request.body() + expected_signature = hmac.new( + webhook.secret.encode(), body, hashlib.sha256 + ).hexdigest() + + # Compare signatures + if not hmac.compare_digest(signature, expected_signature): + raise ValueError("Invalid webhook signature") + + return payload, event_type + + async def _register_webhook( + self, + credentials: Credentials, + webhook_type: str, + resource: str, + events: list[str], + ingress_url: str, + secret: str, + ) -> tuple[str, dict]: + """Register webhook with Exa API.""" + if not isinstance(credentials, APIKeyCredentials): + raise ValueError("Exa webhooks require API key credentials") + api_key = credentials.api_key.get_secret_value() + + # Create webhook via Exa API + response = await Requests().post( + "https://api.exa.ai/v0/webhooks", + headers={"x-api-key": api_key}, + json={ + "url": ingress_url, + "events": events, + "metadata": { + "resource": resource, + "webhook_type": webhook_type, + }, + }, + ) + + if not response.ok: + error_data = response.json() + raise Exception(f"Failed to create Exa webhook: {error_data}") + + webhook_data = response.json() + + # Store the secret returned by Exa + return webhook_data["id"], { + "events": events, + "resource": resource, + "exa_secret": webhook_data.get("secret"), + } + + async def _deregister_webhook( + self, webhook: Webhook, credentials: Credentials + ) -> None: + """Deregister webhook from Exa API.""" + if not isinstance(credentials, APIKeyCredentials): + raise ValueError("Exa webhooks require API key credentials") + api_key = credentials.api_key.get_secret_value() + + # Delete webhook via Exa API + response = await Requests().delete( + f"https://api.exa.ai/v0/webhooks/{webhook.provider_webhook_id}", + headers={"x-api-key": api_key}, + ) + + if not response.ok and response.status != 404: + error_data = response.json() + raise Exception(f"Failed to delete Exa webhook: {error_data}") diff --git a/autogpt_platform/backend/backend/blocks/exa/answers.py b/autogpt_platform/backend/backend/blocks/exa/answers.py new file mode 100644 index 000000000000..fa3f6b403f44 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/exa/answers.py @@ -0,0 +1,121 @@ +from backend.sdk import ( + APIKeyCredentials, + BaseModel, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + Requests, + SchemaField, +) + +from ._config import exa + + +class CostBreakdown(BaseModel): + keywordSearch: float + neuralSearch: float + contentText: float + contentHighlight: float + contentSummary: float + + +class SearchBreakdown(BaseModel): + search: float + contents: float + breakdown: CostBreakdown + + +class PerRequestPrices(BaseModel): + neuralSearch_1_25_results: float + neuralSearch_26_100_results: float + neuralSearch_100_plus_results: float + keywordSearch_1_100_results: float + keywordSearch_100_plus_results: float + + +class PerPagePrices(BaseModel): + contentText: float + contentHighlight: float + contentSummary: float + + +class CostDollars(BaseModel): + total: float + breakDown: list[SearchBreakdown] + perRequestPrices: PerRequestPrices + perPagePrices: PerPagePrices + + +class ExaAnswerBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput = exa.credentials_field( + description="The Exa integration requires an API Key." + ) + query: str = SchemaField( + description="The question or query to answer", + placeholder="What is the latest valuation of SpaceX?", + ) + text: bool = SchemaField( + default=False, + description="If true, the response includes full text content in the search results", + advanced=True, + ) + model: str = SchemaField( + default="exa", + description="The search model to use (exa or exa-pro)", + placeholder="exa", + advanced=True, + ) + + class Output(BlockSchema): + answer: str = SchemaField( + description="The generated answer based on search results" + ) + citations: list[dict] = SchemaField( + description="Search results used to generate the answer", + default_factory=list, + ) + cost_dollars: CostDollars = SchemaField( + description="Cost breakdown of the request" + ) + error: str = SchemaField( + description="Error message if the request failed", default="" + ) + + def __init__(self): + super().__init__( + id="b79ca4cc-9d5e-47d1-9d4f-e3a2d7f28df5", + description="Get an LLM answer to a question informed by Exa search results", + categories={BlockCategory.SEARCH, BlockCategory.AI}, + input_schema=ExaAnswerBlock.Input, + output_schema=ExaAnswerBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + url = "https://api.exa.ai/answer" + headers = { + "Content-Type": "application/json", + "x-api-key": credentials.api_key.get_secret_value(), + } + + # Build the payload + payload = { + "query": input_data.query, + "text": input_data.text, + "model": input_data.model, + } + + try: + response = await Requests().post(url, headers=headers, json=payload) + data = response.json() + + yield "answer", data.get("answer", "") + yield "citations", data.get("citations", []) + yield "cost_dollars", data.get("costDollars", {}) + + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/exa/contents.py b/autogpt_platform/backend/backend/blocks/exa/contents.py new file mode 100644 index 000000000000..ec537232d2e4 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/exa/contents.py @@ -0,0 +1,69 @@ +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + Requests, + SchemaField, +) + +from ._config import exa +from .helpers import ContentSettings + + +class ExaContentsBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput = exa.credentials_field( + description="The Exa integration requires an API Key." + ) + ids: list[str] = SchemaField( + description="Array of document IDs obtained from searches" + ) + contents: ContentSettings = SchemaField( + description="Content retrieval settings", + default=ContentSettings(), + advanced=True, + ) + + class Output(BlockSchema): + results: list = SchemaField( + description="List of document contents", default_factory=list + ) + error: str = SchemaField( + description="Error message if the request failed", default="" + ) + + def __init__(self): + super().__init__( + id="c52be83f-f8cd-4180-b243-af35f986b461", + description="Retrieves document contents using Exa's contents API", + categories={BlockCategory.SEARCH}, + input_schema=ExaContentsBlock.Input, + output_schema=ExaContentsBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + url = "https://api.exa.ai/contents" + headers = { + "Content-Type": "application/json", + "x-api-key": credentials.api_key.get_secret_value(), + } + + # Convert ContentSettings to API format + payload = { + "ids": input_data.ids, + "text": input_data.contents.text, + "highlights": input_data.contents.highlights, + "summary": input_data.contents.summary, + } + + try: + response = await Requests().post(url, headers=headers, json=payload) + data = response.json() + yield "results", data.get("results", []) + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/exa/helpers.py b/autogpt_platform/backend/backend/blocks/exa/helpers.py new file mode 100644 index 000000000000..d32ea2dc642e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/exa/helpers.py @@ -0,0 +1,129 @@ +from typing import Optional + +from backend.sdk import BaseModel, SchemaField + + +class TextSettings(BaseModel): + max_characters: int = SchemaField( + default=1000, + description="Maximum number of characters to return", + placeholder="1000", + ) + include_html_tags: bool = SchemaField( + default=False, + description="Whether to include HTML tags in the text", + placeholder="False", + ) + + +class HighlightSettings(BaseModel): + num_sentences: int = SchemaField( + default=3, + description="Number of sentences per highlight", + placeholder="3", + ) + highlights_per_url: int = SchemaField( + default=3, + description="Number of highlights per URL", + placeholder="3", + ) + + +class SummarySettings(BaseModel): + query: Optional[str] = SchemaField( + default="", + description="Query string for summarization", + placeholder="Enter query", + ) + + +class ContentSettings(BaseModel): + text: TextSettings = SchemaField( + default=TextSettings(), + ) + highlights: HighlightSettings = SchemaField( + default=HighlightSettings(), + ) + summary: SummarySettings = SchemaField( + default=SummarySettings(), + ) + + +# Websets Models +class WebsetEntitySettings(BaseModel): + type: Optional[str] = SchemaField( + default=None, + description="Entity type (e.g., 'company', 'person')", + placeholder="company", + ) + + +class WebsetCriterion(BaseModel): + description: str = SchemaField( + description="Description of the criterion", + placeholder="Must be based in the US", + ) + success_rate: Optional[int] = SchemaField( + default=None, + description="Success rate percentage", + ge=0, + le=100, + ) + + +class WebsetSearchConfig(BaseModel): + query: str = SchemaField( + description="Search query", + placeholder="Marketing agencies based in the US", + ) + count: int = SchemaField( + default=10, + description="Number of results to return", + ge=1, + le=100, + ) + entity: Optional[WebsetEntitySettings] = SchemaField( + default=None, + description="Entity settings for the search", + ) + criteria: Optional[list[WebsetCriterion]] = SchemaField( + default=None, + description="Search criteria", + ) + behavior: Optional[str] = SchemaField( + default="override", + description="Behavior when updating results ('override' or 'append')", + placeholder="override", + ) + + +class EnrichmentOption(BaseModel): + label: str = SchemaField( + description="Label for the enrichment option", + placeholder="Option 1", + ) + + +class WebsetEnrichmentConfig(BaseModel): + title: str = SchemaField( + description="Title of the enrichment", + placeholder="Company Details", + ) + description: str = SchemaField( + description="Description of what this enrichment does", + placeholder="Extract company information", + ) + format: str = SchemaField( + default="text", + description="Format of the enrichment result", + placeholder="text", + ) + instructions: Optional[str] = SchemaField( + default=None, + description="Instructions for the enrichment", + placeholder="Extract key company metrics", + ) + options: Optional[list[EnrichmentOption]] = SchemaField( + default=None, + description="Options for the enrichment", + ) diff --git a/autogpt_platform/backend/backend/blocks/exa/model.py b/autogpt_platform/backend/backend/blocks/exa/model.py new file mode 100644 index 000000000000..69b223f46743 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/exa/model.py @@ -0,0 +1,247 @@ +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +# Enum definitions based on available options +class WebsetStatus(str, Enum): + IDLE = "idle" + PENDING = "pending" + RUNNING = "running" + PAUSED = "paused" + + +class WebsetSearchStatus(str, Enum): + CREATED = "created" + # Add more if known, based on example it's "created" + + +class ImportStatus(str, Enum): + PENDING = "pending" + # Add more if known + + +class ImportFormat(str, Enum): + CSV = "csv" + # Add more if known + + +class EnrichmentStatus(str, Enum): + PENDING = "pending" + # Add more if known + + +class EnrichmentFormat(str, Enum): + TEXT = "text" + # Add more if known + + +class MonitorStatus(str, Enum): + ENABLED = "enabled" + # Add more if known + + +class MonitorBehaviorType(str, Enum): + SEARCH = "search" + # Add more if known + + +class MonitorRunStatus(str, Enum): + CREATED = "created" + # Add more if known + + +class CanceledReason(str, Enum): + WEBSET_DELETED = "webset_deleted" + # Add more if known + + +class FailedReason(str, Enum): + INVALID_FORMAT = "invalid_format" + # Add more if known + + +class Confidence(str, Enum): + HIGH = "high" + # Add more if known + + +# Nested models + + +class Entity(BaseModel): + type: str + + +class Criterion(BaseModel): + description: str + successRate: Optional[int] = None + + +class ExcludeItem(BaseModel): + source: str = Field(default="import") + id: str + + +class Relationship(BaseModel): + definition: str + limit: Optional[float] = None + + +class ScopeItem(BaseModel): + source: str = Field(default="import") + id: str + relationship: Optional[Relationship] = None + + +class Progress(BaseModel): + found: int + analyzed: int + completion: int + timeLeft: int + + +class Bounds(BaseModel): + min: int + max: int + + +class Expected(BaseModel): + total: int + confidence: str = Field(default="high") # Use str or Confidence enum + bounds: Bounds + + +class Recall(BaseModel): + expected: Expected + reasoning: str + + +class WebsetSearch(BaseModel): + id: str + object: str = Field(default="webset_search") + status: str = Field(default="created") # Or use WebsetSearchStatus + websetId: str + query: str + entity: Entity + criteria: List[Criterion] + count: int + behavior: str = Field(default="override") + exclude: List[ExcludeItem] + scope: List[ScopeItem] + progress: Progress + recall: Recall + metadata: Dict[str, Any] = Field(default_factory=dict) + canceledAt: Optional[datetime] = None + canceledReason: Optional[str] = Field(default=None) # Or use CanceledReason + createdAt: datetime + updatedAt: datetime + + +class ImportEntity(BaseModel): + type: str + + +class Import(BaseModel): + id: str + object: str = Field(default="import") + status: str = Field(default="pending") # Or use ImportStatus + format: str = Field(default="csv") # Or use ImportFormat + entity: ImportEntity + title: str + count: int + metadata: Dict[str, Any] = Field(default_factory=dict) + failedReason: Optional[str] = Field(default=None) # Or use FailedReason + failedAt: Optional[datetime] = None + failedMessage: Optional[str] = None + createdAt: datetime + updatedAt: datetime + + +class Option(BaseModel): + label: str + + +class WebsetEnrichment(BaseModel): + id: str + object: str = Field(default="webset_enrichment") + status: str = Field(default="pending") # Or use EnrichmentStatus + websetId: str + title: str + description: str + format: str = Field(default="text") # Or use EnrichmentFormat + options: List[Option] + instructions: str + metadata: Dict[str, Any] = Field(default_factory=dict) + createdAt: datetime + updatedAt: datetime + + +class Cadence(BaseModel): + cron: str + timezone: str = Field(default="Etc/UTC") + + +class BehaviorConfig(BaseModel): + query: Optional[str] = None + criteria: Optional[List[Criterion]] = None + entity: Optional[Entity] = None + count: Optional[int] = None + behavior: Optional[str] = Field(default=None) + + +class Behavior(BaseModel): + type: str = Field(default="search") # Or use MonitorBehaviorType + config: BehaviorConfig + + +class MonitorRun(BaseModel): + id: str + object: str = Field(default="monitor_run") + status: str = Field(default="created") # Or use MonitorRunStatus + monitorId: str + type: str = Field(default="search") + completedAt: Optional[datetime] = None + failedAt: Optional[datetime] = None + failedReason: Optional[str] = None + canceledAt: Optional[datetime] = None + createdAt: datetime + updatedAt: datetime + + +class Monitor(BaseModel): + id: str + object: str = Field(default="monitor") + status: str = Field(default="enabled") # Or use MonitorStatus + websetId: str + cadence: Cadence + behavior: Behavior + lastRun: Optional[MonitorRun] = None + nextRunAt: Optional[datetime] = None + metadata: Dict[str, Any] = Field(default_factory=dict) + createdAt: datetime + updatedAt: datetime + + +class Webset(BaseModel): + id: str + object: str = Field(default="webset") + status: WebsetStatus + externalId: Optional[str] = None + title: Optional[str] = None + searches: List[WebsetSearch] + imports: List[Import] + enrichments: List[WebsetEnrichment] + monitors: List[Monitor] + streams: List[Any] + createdAt: datetime + updatedAt: datetime + metadata: Dict[str, Any] = Field(default_factory=dict) + + +class ListWebsets(BaseModel): + data: List[Webset] + hasMore: bool + nextCursor: Optional[str] = None diff --git a/autogpt_platform/backend/backend/blocks/exa/search.py b/autogpt_platform/backend/backend/blocks/exa/search.py index ed3270f46a81..4bc772f7f7ba 100644 --- a/autogpt_platform/backend/backend/blocks/exa/search.py +++ b/autogpt_platform/backend/backend/blocks/exa/search.py @@ -1,90 +1,74 @@ from datetime import datetime -from typing import List -from pydantic import BaseModel - -from backend.blocks.exa._auth import ( - ExaCredentials, - ExaCredentialsField, - ExaCredentialsInput, +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + Requests, + SchemaField, ) -from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema -from backend.data.model import SchemaField -from backend.util.request import requests - -class ContentSettings(BaseModel): - text: dict = SchemaField( - description="Text content settings", - default={"maxCharacters": 1000, "includeHtmlTags": False}, - ) - highlights: dict = SchemaField( - description="Highlight settings", - default={"numSentences": 3, "highlightsPerUrl": 3}, - ) - summary: dict = SchemaField( - description="Summary settings", - default={"query": ""}, - ) +from ._config import exa +from .helpers import ContentSettings class ExaSearchBlock(Block): class Input(BlockSchema): - credentials: ExaCredentialsInput = ExaCredentialsField() - query: str = SchemaField(description="The search query") - useAutoprompt: bool = SchemaField( - description="Whether to use autoprompt", - default=True, + credentials: CredentialsMetaInput = exa.credentials_field( + description="The Exa integration requires an API Key." ) - type: str = SchemaField( - description="Type of search", - default="", + query: str = SchemaField(description="The search query") + use_auto_prompt: bool = SchemaField( + description="Whether to use autoprompt", default=True, advanced=True ) + type: str = SchemaField(description="Type of search", default="", advanced=True) category: str = SchemaField( - description="Category to search within", - default="", + description="Category to search within", default="", advanced=True ) - numResults: int = SchemaField( - description="Number of results to return", - default=10, + number_of_results: int = SchemaField( + description="Number of results to return", default=10, advanced=True ) - includeDomains: List[str] = SchemaField( - description="Domains to include in search", - default=[], + include_domains: list[str] = SchemaField( + description="Domains to include in search", default_factory=list ) - excludeDomains: List[str] = SchemaField( + exclude_domains: list[str] = SchemaField( description="Domains to exclude from search", - default=[], + default_factory=list, + advanced=True, ) - startCrawlDate: datetime = SchemaField( - description="Start date for crawled content", + start_crawl_date: datetime = SchemaField( + description="Start date for crawled content" ) - endCrawlDate: datetime = SchemaField( - description="End date for crawled content", + end_crawl_date: datetime = SchemaField( + description="End date for crawled content" ) - startPublishedDate: datetime = SchemaField( - description="Start date for published content", + start_published_date: datetime = SchemaField( + description="Start date for published content" ) - endPublishedDate: datetime = SchemaField( - description="End date for published content", + end_published_date: datetime = SchemaField( + description="End date for published content" ) - includeText: List[str] = SchemaField( - description="Text patterns to include", - default=[], + include_text: list[str] = SchemaField( + description="Text patterns to include", default_factory=list, advanced=True ) - excludeText: List[str] = SchemaField( - description="Text patterns to exclude", - default=[], + exclude_text: list[str] = SchemaField( + description="Text patterns to exclude", default_factory=list, advanced=True ) contents: ContentSettings = SchemaField( description="Content retrieval settings", default=ContentSettings(), + advanced=True, ) class Output(BlockSchema): results: list = SchemaField( - description="List of search results", - default=[], + description="List of search results", default_factory=list + ) + error: str = SchemaField( + description="Error message if the request failed", ) def __init__(self): @@ -96,8 +80,8 @@ def __init__(self): output_schema=ExaSearchBlock.Output, ) - def run( - self, input_data: Input, *, credentials: ExaCredentials, **kwargs + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: url = "https://api.exa.ai/search" headers = { @@ -107,51 +91,43 @@ def run( payload = { "query": input_data.query, - "useAutoprompt": input_data.useAutoprompt, - "numResults": input_data.numResults, - "contents": { - "text": {"maxCharacters": 1000, "includeHtmlTags": False}, - "highlights": { - "numSentences": 3, - "highlightsPerUrl": 3, - }, - "summary": {"query": ""}, - }, + "useAutoprompt": input_data.use_auto_prompt, + "numResults": input_data.number_of_results, + "contents": input_data.contents.model_dump(), + } + + date_field_mapping = { + "start_crawl_date": "startCrawlDate", + "end_crawl_date": "endCrawlDate", + "start_published_date": "startPublishedDate", + "end_published_date": "endPublishedDate", } # Add dates if they exist - date_fields = [ - "startCrawlDate", - "endCrawlDate", - "startPublishedDate", - "endPublishedDate", - ] - for field in date_fields: - value = getattr(input_data, field, None) + for input_field, api_field in date_field_mapping.items(): + value = getattr(input_data, input_field, None) if value: - payload[field] = value.strftime("%Y-%m-%dT%H:%M:%S.000Z") + payload[api_field] = value.strftime("%Y-%m-%dT%H:%M:%S.000Z") - # Add other fields - optional_fields = [ - "type", - "category", - "includeDomains", - "excludeDomains", - "includeText", - "excludeText", - ] + optional_field_mapping = { + "type": "type", + "category": "category", + "include_domains": "includeDomains", + "exclude_domains": "excludeDomains", + "include_text": "includeText", + "exclude_text": "excludeText", + } - for field in optional_fields: - value = getattr(input_data, field) + # Add other fields + for input_field, api_field in optional_field_mapping.items(): + value = getattr(input_data, input_field) if value: # Only add non-empty values - payload[field] = value + payload[api_field] = value try: - response = requests.post(url, headers=headers, json=payload) - response.raise_for_status() + response = await Requests().post(url, headers=headers, json=payload) data = response.json() # Extract just the results array from the response yield "results", data.get("results", []) except Exception as e: yield "error", str(e) - yield "results", [] diff --git a/autogpt_platform/backend/backend/blocks/exa/similar.py b/autogpt_platform/backend/backend/blocks/exa/similar.py new file mode 100644 index 000000000000..940b9676c8be --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/exa/similar.py @@ -0,0 +1,132 @@ +from datetime import datetime +from typing import Any + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + Requests, + SchemaField, +) + +from ._config import exa +from .helpers import ContentSettings + + +class ExaFindSimilarBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput = exa.credentials_field( + description="The Exa integration requires an API Key." + ) + url: str = SchemaField( + description="The url for which you would like to find similar links" + ) + number_of_results: int = SchemaField( + description="Number of results to return", default=10, advanced=True + ) + include_domains: list[str] = SchemaField( + description="Domains to include in search", + default_factory=list, + advanced=True, + ) + exclude_domains: list[str] = SchemaField( + description="Domains to exclude from search", + default_factory=list, + advanced=True, + ) + start_crawl_date: datetime = SchemaField( + description="Start date for crawled content" + ) + end_crawl_date: datetime = SchemaField( + description="End date for crawled content" + ) + start_published_date: datetime = SchemaField( + description="Start date for published content" + ) + end_published_date: datetime = SchemaField( + description="End date for published content" + ) + include_text: list[str] = SchemaField( + description="Text patterns to include (max 1 string, up to 5 words)", + default_factory=list, + advanced=True, + ) + exclude_text: list[str] = SchemaField( + description="Text patterns to exclude (max 1 string, up to 5 words)", + default_factory=list, + advanced=True, + ) + contents: ContentSettings = SchemaField( + description="Content retrieval settings", + default=ContentSettings(), + advanced=True, + ) + + class Output(BlockSchema): + results: list[Any] = SchemaField( + description="List of similar documents with title, URL, published date, author, and score", + default_factory=list, + ) + error: str = SchemaField( + description="Error message if the request failed", default="" + ) + + def __init__(self): + super().__init__( + id="5e7315d1-af61-4a0c-9350-7c868fa7438a", + description="Finds similar links using Exa's findSimilar API", + categories={BlockCategory.SEARCH}, + input_schema=ExaFindSimilarBlock.Input, + output_schema=ExaFindSimilarBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + url = "https://api.exa.ai/findSimilar" + headers = { + "Content-Type": "application/json", + "x-api-key": credentials.api_key.get_secret_value(), + } + + payload = { + "url": input_data.url, + "numResults": input_data.number_of_results, + "contents": input_data.contents.model_dump(), + } + + optional_field_mapping = { + "include_domains": "includeDomains", + "exclude_domains": "excludeDomains", + "include_text": "includeText", + "exclude_text": "excludeText", + } + + # Add optional fields if they have values + for input_field, api_field in optional_field_mapping.items(): + value = getattr(input_data, input_field) + if value: # Only add non-empty values + payload[api_field] = value + + date_field_mapping = { + "start_crawl_date": "startCrawlDate", + "end_crawl_date": "endCrawlDate", + "start_published_date": "startPublishedDate", + "end_published_date": "endPublishedDate", + } + + # Add dates if they exist + for input_field, api_field in date_field_mapping.items(): + value = getattr(input_data, input_field, None) + if value: + payload[api_field] = value.strftime("%Y-%m-%dT%H:%M:%S.000Z") + + try: + response = await Requests().post(url, headers=headers, json=payload) + data = response.json() + yield "results", data.get("results", []) + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/exa/webhook_blocks.py b/autogpt_platform/backend/backend/blocks/exa/webhook_blocks.py new file mode 100644 index 000000000000..eb3854ed9c3f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/exa/webhook_blocks.py @@ -0,0 +1,202 @@ +""" +Exa Webhook Blocks + +These blocks handle webhook events from Exa's API for websets and other events. +""" + +from backend.sdk import ( + BaseModel, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockType, + BlockWebhookConfig, + CredentialsMetaInput, + Field, + ProviderName, + SchemaField, +) + +from ._config import exa +from ._webhook import ExaEventType + + +class WebsetEventFilter(BaseModel): + """Filter configuration for Exa webset events.""" + + webset_created: bool = Field( + default=True, description="Receive notifications when websets are created" + ) + webset_deleted: bool = Field( + default=False, description="Receive notifications when websets are deleted" + ) + webset_paused: bool = Field( + default=False, description="Receive notifications when websets are paused" + ) + webset_idle: bool = Field( + default=False, description="Receive notifications when websets become idle" + ) + search_created: bool = Field( + default=True, + description="Receive notifications when webset searches are created", + ) + search_completed: bool = Field( + default=True, description="Receive notifications when webset searches complete" + ) + search_canceled: bool = Field( + default=False, + description="Receive notifications when webset searches are canceled", + ) + search_updated: bool = Field( + default=False, + description="Receive notifications when webset searches are updated", + ) + item_created: bool = Field( + default=True, description="Receive notifications when webset items are created" + ) + item_enriched: bool = Field( + default=True, description="Receive notifications when webset items are enriched" + ) + export_created: bool = Field( + default=False, + description="Receive notifications when webset exports are created", + ) + export_completed: bool = Field( + default=True, description="Receive notifications when webset exports complete" + ) + import_created: bool = Field( + default=False, description="Receive notifications when imports are created" + ) + import_completed: bool = Field( + default=True, description="Receive notifications when imports complete" + ) + import_processing: bool = Field( + default=False, description="Receive notifications when imports are processing" + ) + + +class ExaWebsetWebhookBlock(Block): + """ + Receives webhook notifications for Exa webset events. + + This block allows you to monitor various events related to Exa websets, + including creation, updates, searches, and exports. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = exa.credentials_field( + description="Exa API credentials for webhook management" + ) + webhook_url: str = SchemaField( + description="URL to receive webhooks (auto-generated)", + default="", + hidden=True, + ) + webset_id: str = SchemaField( + description="The webset ID to monitor (optional, monitors all if empty)", + default="", + ) + event_filter: WebsetEventFilter = SchemaField( + description="Configure which events to receive", default=WebsetEventFilter() + ) + payload: dict = SchemaField( + description="Webhook payload data", default={}, hidden=True + ) + + class Output(BlockSchema): + event_type: str = SchemaField(description="Type of event that occurred") + event_id: str = SchemaField(description="Unique identifier for this event") + webset_id: str = SchemaField(description="ID of the affected webset") + data: dict = SchemaField(description="Event-specific data") + timestamp: str = SchemaField(description="When the event occurred") + metadata: dict = SchemaField(description="Additional event metadata") + + def __init__(self): + super().__init__( + disabled=True, + id="d0204ed8-8b81-408d-8b8d-ed087a546228", + description="Receive webhook notifications for Exa webset events", + categories={BlockCategory.INPUT}, + input_schema=ExaWebsetWebhookBlock.Input, + output_schema=ExaWebsetWebhookBlock.Output, + block_type=BlockType.WEBHOOK, + webhook_config=BlockWebhookConfig( + provider=ProviderName("exa"), + webhook_type="webset", + event_filter_input="event_filter", + resource_format="{webset_id}", + ), + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + """Process incoming Exa webhook payload.""" + try: + payload = input_data.payload + + # Extract event details + event_type = payload.get("eventType", "unknown") + event_id = payload.get("eventId", "") + + # Get webset ID from payload or input + webset_id = payload.get("websetId", input_data.webset_id) + + # Check if we should process this event based on filter + should_process = self._should_process_event( + event_type, input_data.event_filter + ) + + if not should_process: + # Skip events that don't match our filter + return + + # Extract event data + event_data = payload.get("data", {}) + timestamp = payload.get("occurredAt", payload.get("createdAt", "")) + metadata = payload.get("metadata", {}) + + yield "event_type", event_type + yield "event_id", event_id + yield "webset_id", webset_id + yield "data", event_data + yield "timestamp", timestamp + yield "metadata", metadata + + except Exception as e: + # Handle errors gracefully + yield "event_type", "error" + yield "event_id", "" + yield "webset_id", input_data.webset_id + yield "data", {"error": str(e)} + yield "timestamp", "" + yield "metadata", {} + + def _should_process_event( + self, event_type: str, event_filter: WebsetEventFilter + ) -> bool: + """Check if an event should be processed based on the filter.""" + filter_mapping = { + ExaEventType.WEBSET_CREATED: event_filter.webset_created, + ExaEventType.WEBSET_DELETED: event_filter.webset_deleted, + ExaEventType.WEBSET_PAUSED: event_filter.webset_paused, + ExaEventType.WEBSET_IDLE: event_filter.webset_idle, + ExaEventType.WEBSET_SEARCH_CREATED: event_filter.search_created, + ExaEventType.WEBSET_SEARCH_COMPLETED: event_filter.search_completed, + ExaEventType.WEBSET_SEARCH_CANCELED: event_filter.search_canceled, + ExaEventType.WEBSET_SEARCH_UPDATED: event_filter.search_updated, + ExaEventType.WEBSET_ITEM_CREATED: event_filter.item_created, + ExaEventType.WEBSET_ITEM_ENRICHED: event_filter.item_enriched, + ExaEventType.WEBSET_EXPORT_CREATED: event_filter.export_created, + ExaEventType.WEBSET_EXPORT_COMPLETED: event_filter.export_completed, + ExaEventType.IMPORT_CREATED: event_filter.import_created, + ExaEventType.IMPORT_COMPLETED: event_filter.import_completed, + ExaEventType.IMPORT_PROCESSING: event_filter.import_processing, + } + + # Try to convert string to ExaEventType enum + try: + event_type_enum = ExaEventType(event_type) + return filter_mapping.get(event_type_enum, True) + except ValueError: + # If event_type is not a valid enum value, process it by default + return True diff --git a/autogpt_platform/backend/backend/blocks/exa/websets.py b/autogpt_platform/backend/backend/blocks/exa/websets.py new file mode 100644 index 000000000000..bec57e01d91c --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/exa/websets.py @@ -0,0 +1,752 @@ +from datetime import datetime +from enum import Enum +from typing import Annotated, Any, Dict, List, Optional + +from exa_py import Exa +from exa_py.websets.types import ( + CreateCriterionParameters, + CreateEnrichmentParameters, + CreateWebsetParameters, + CreateWebsetParametersSearch, + ExcludeItem, + Format, + ImportItem, + ImportSource, + Option, + ScopeItem, + ScopeRelationship, + ScopeSourceType, + WebsetArticleEntity, + WebsetCompanyEntity, + WebsetCustomEntity, + WebsetPersonEntity, + WebsetResearchPaperEntity, + WebsetStatus, +) +from pydantic import Field + +from backend.sdk import ( + APIKeyCredentials, + BaseModel, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + Requests, + SchemaField, +) + +from ._config import exa + + +class SearchEntityType(str, Enum): + COMPANY = "company" + PERSON = "person" + ARTICLE = "article" + RESEARCH_PAPER = "research_paper" + CUSTOM = "custom" + AUTO = "auto" + + +class SearchType(str, Enum): + IMPORT = "import" + WEBSET = "webset" + + +class EnrichmentFormat(str, Enum): + TEXT = "text" + DATE = "date" + NUMBER = "number" + OPTIONS = "options" + EMAIL = "email" + PHONE = "phone" + + +class Webset(BaseModel): + id: str + status: WebsetStatus | None = Field(..., title="WebsetStatus") + """ + The status of the webset + """ + external_id: Annotated[Optional[str], Field(alias="externalId")] = None + """ + The external identifier for the webset + NOTE: Returning dict to avoid ui crashing due to nested objects + """ + searches: List[dict[str, Any]] | None = None + """ + The searches that have been performed on the webset. + NOTE: Returning dict to avoid ui crashing due to nested objects + """ + enrichments: List[dict[str, Any]] | None = None + """ + The Enrichments to apply to the Webset Items. + NOTE: Returning dict to avoid ui crashing due to nested objects + """ + monitors: List[dict[str, Any]] | None = None + """ + The Monitors for the Webset. + NOTE: Returning dict to avoid ui crashing due to nested objects + """ + metadata: Optional[Dict[str, Any]] = {} + """ + Set of key-value pairs you want to associate with this object. + """ + created_at: Annotated[datetime, Field(alias="createdAt")] | None = None + """ + The date and time the webset was created + """ + updated_at: Annotated[datetime, Field(alias="updatedAt")] | None = None + """ + The date and time the webset was last updated + """ + + +class ExaCreateWebsetBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput = exa.credentials_field( + description="The Exa integration requires an API Key." + ) + + # Search parameters (flattened) + search_query: str = SchemaField( + description="Your search query. Use this to describe what you are looking for. Any URL provided will be crawled and used as context for the search.", + placeholder="Marketing agencies based in the US, that focus on consumer products", + ) + search_count: Optional[int] = SchemaField( + default=10, + description="Number of items the search will attempt to find. The actual number of items found may be less than this number depending on the search complexity.", + ge=1, + le=1000, + ) + search_entity_type: SearchEntityType = SchemaField( + default=SearchEntityType.AUTO, + description="Entity type: 'company', 'person', 'article', 'research_paper', or 'custom'. If not provided, we automatically detect the entity from the query.", + advanced=True, + ) + search_entity_description: Optional[str] = SchemaField( + default=None, + description="Description for custom entity type (required when search_entity_type is 'custom')", + advanced=True, + ) + + # Search criteria (flattened) + search_criteria: list[str] = SchemaField( + default_factory=list, + description="List of criteria descriptions that every item will be evaluated against. If not provided, we automatically detect the criteria from the query.", + advanced=True, + ) + + # Search exclude sources (flattened) + search_exclude_sources: list[str] = SchemaField( + default_factory=list, + description="List of source IDs (imports or websets) to exclude from search results", + advanced=True, + ) + search_exclude_types: list[SearchType] = SchemaField( + default_factory=list, + description="List of source types corresponding to exclude sources ('import' or 'webset')", + advanced=True, + ) + + # Search scope sources (flattened) + search_scope_sources: list[str] = SchemaField( + default_factory=list, + description="List of source IDs (imports or websets) to limit search scope to", + advanced=True, + ) + search_scope_types: list[SearchType] = SchemaField( + default_factory=list, + description="List of source types corresponding to scope sources ('import' or 'webset')", + advanced=True, + ) + search_scope_relationships: list[str] = SchemaField( + default_factory=list, + description="List of relationship definitions for hop searches (optional, one per scope source)", + advanced=True, + ) + search_scope_relationship_limits: list[int] = SchemaField( + default_factory=list, + description="List of limits on the number of related entities to find (optional, one per scope relationship)", + advanced=True, + ) + + # Import parameters (flattened) + import_sources: list[str] = SchemaField( + default_factory=list, + description="List of source IDs to import from", + advanced=True, + ) + import_types: list[SearchType] = SchemaField( + default_factory=list, + description="List of source types corresponding to import sources ('import' or 'webset')", + advanced=True, + ) + + # Enrichment parameters (flattened) + enrichment_descriptions: list[str] = SchemaField( + default_factory=list, + description="List of enrichment task descriptions to perform on each webset item", + advanced=True, + ) + enrichment_formats: list[EnrichmentFormat] = SchemaField( + default_factory=list, + description="List of formats for enrichment responses ('text', 'date', 'number', 'options', 'email', 'phone'). If not specified, we automatically select the best format.", + advanced=True, + ) + enrichment_options: list[list[str]] = SchemaField( + default_factory=list, + description="List of option lists for enrichments with 'options' format. Each inner list contains the option labels.", + advanced=True, + ) + enrichment_metadata: list[dict] = SchemaField( + default_factory=list, + description="List of metadata dictionaries for enrichments", + advanced=True, + ) + + # Webset metadata + external_id: Optional[str] = SchemaField( + default=None, + description="External identifier for the webset. You can use this to reference the webset by your own internal identifiers.", + placeholder="my-webset-123", + advanced=True, + ) + metadata: Optional[dict] = SchemaField( + default_factory=dict, + description="Key-value pairs to associate with this webset", + advanced=True, + ) + + class Output(BlockSchema): + webset: Webset = SchemaField( + description="The unique identifier for the created webset" + ) + + def __init__(self): + super().__init__( + id="0cda29ff-c549-4a19-8805-c982b7d4ec34", + description="Create a new Exa Webset for persistent web search collections", + categories={BlockCategory.SEARCH}, + input_schema=ExaCreateWebsetBlock.Input, + output_schema=ExaCreateWebsetBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + + exa = Exa(credentials.api_key.get_secret_value()) + + # ------------------------------------------------------------ + # Build entity (if explicitly provided) + # ------------------------------------------------------------ + entity = None + if input_data.search_entity_type == SearchEntityType.COMPANY: + entity = WebsetCompanyEntity(type="company") + elif input_data.search_entity_type == SearchEntityType.PERSON: + entity = WebsetPersonEntity(type="person") + elif input_data.search_entity_type == SearchEntityType.ARTICLE: + entity = WebsetArticleEntity(type="article") + elif input_data.search_entity_type == SearchEntityType.RESEARCH_PAPER: + entity = WebsetResearchPaperEntity(type="research_paper") + elif ( + input_data.search_entity_type == SearchEntityType.CUSTOM + and input_data.search_entity_description + ): + entity = WebsetCustomEntity( + type="custom", description=input_data.search_entity_description + ) + + # ------------------------------------------------------------ + # Build criteria list + # ------------------------------------------------------------ + criteria = None + if input_data.search_criteria: + criteria = [ + CreateCriterionParameters(description=item) + for item in input_data.search_criteria + ] + + # ------------------------------------------------------------ + # Build exclude sources list + # ------------------------------------------------------------ + exclude_items = None + if input_data.search_exclude_sources: + exclude_items = [] + for idx, src_id in enumerate(input_data.search_exclude_sources): + src_type = None + if input_data.search_exclude_types and idx < len( + input_data.search_exclude_types + ): + src_type = input_data.search_exclude_types[idx] + # Default to IMPORT if type missing + if src_type == SearchType.WEBSET: + source_enum = ImportSource.webset + else: + source_enum = ImportSource.import_ + exclude_items.append(ExcludeItem(source=source_enum, id=src_id)) + + # ------------------------------------------------------------ + # Build scope list + # ------------------------------------------------------------ + scope_items = None + if input_data.search_scope_sources: + scope_items = [] + for idx, src_id in enumerate(input_data.search_scope_sources): + src_type = None + if input_data.search_scope_types and idx < len( + input_data.search_scope_types + ): + src_type = input_data.search_scope_types[idx] + relationship = None + if input_data.search_scope_relationships and idx < len( + input_data.search_scope_relationships + ): + rel_def = input_data.search_scope_relationships[idx] + lim = None + if input_data.search_scope_relationship_limits and idx < len( + input_data.search_scope_relationship_limits + ): + lim = input_data.search_scope_relationship_limits[idx] + relationship = ScopeRelationship(definition=rel_def, limit=lim) + if src_type == SearchType.WEBSET: + src_enum = ScopeSourceType.webset + else: + src_enum = ScopeSourceType.import_ + scope_items.append( + ScopeItem(source=src_enum, id=src_id, relationship=relationship) + ) + + # ------------------------------------------------------------ + # Assemble search parameters (only if a query is provided) + # ------------------------------------------------------------ + search_params = None + if input_data.search_query: + search_params = CreateWebsetParametersSearch( + query=input_data.search_query, + count=input_data.search_count, + entity=entity, + criteria=criteria, + exclude=exclude_items, + scope=scope_items, + ) + + # ------------------------------------------------------------ + # Build imports list + # ------------------------------------------------------------ + imports_params = None + if input_data.import_sources: + imports_params = [] + for idx, src_id in enumerate(input_data.import_sources): + src_type = None + if input_data.import_types and idx < len(input_data.import_types): + src_type = input_data.import_types[idx] + if src_type == SearchType.WEBSET: + source_enum = ImportSource.webset + else: + source_enum = ImportSource.import_ + imports_params.append(ImportItem(source=source_enum, id=src_id)) + + # ------------------------------------------------------------ + # Build enrichment list + # ------------------------------------------------------------ + enrichments_params = None + if input_data.enrichment_descriptions: + enrichments_params = [] + for idx, desc in enumerate(input_data.enrichment_descriptions): + fmt = None + if input_data.enrichment_formats and idx < len( + input_data.enrichment_formats + ): + fmt_enum = input_data.enrichment_formats[idx] + if fmt_enum is not None: + fmt = Format( + fmt_enum.value if isinstance(fmt_enum, Enum) else fmt_enum + ) + options_list = None + if input_data.enrichment_options and idx < len( + input_data.enrichment_options + ): + raw_opts = input_data.enrichment_options[idx] + if raw_opts: + options_list = [Option(label=o) for o in raw_opts] + metadata_obj = None + if input_data.enrichment_metadata and idx < len( + input_data.enrichment_metadata + ): + metadata_obj = input_data.enrichment_metadata[idx] + enrichments_params.append( + CreateEnrichmentParameters( + description=desc, + format=fmt, + options=options_list, + metadata=metadata_obj, + ) + ) + + # ------------------------------------------------------------ + # Create the webset + # ------------------------------------------------------------ + webset = exa.websets.create( + params=CreateWebsetParameters( + search=search_params, + imports=imports_params, + enrichments=enrichments_params, + external_id=input_data.external_id, + metadata=input_data.metadata, + ) + ) + + # Use alias field names returned from Exa SDK so that nested models validate correctly + yield "webset", Webset.model_validate(webset.model_dump(by_alias=True)) + + +class ExaUpdateWebsetBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput = exa.credentials_field( + description="The Exa integration requires an API Key." + ) + webset_id: str = SchemaField( + description="The ID or external ID of the Webset to update", + placeholder="webset-id-or-external-id", + ) + metadata: Optional[dict] = SchemaField( + default=None, + description="Key-value pairs to associate with this webset (set to null to clear)", + ) + + class Output(BlockSchema): + webset_id: str = SchemaField(description="The unique identifier for the webset") + status: str = SchemaField(description="The status of the webset") + external_id: Optional[str] = SchemaField( + description="The external identifier for the webset", default=None + ) + metadata: dict = SchemaField( + description="Updated metadata for the webset", default_factory=dict + ) + updated_at: str = SchemaField( + description="The date and time the webset was updated" + ) + error: str = SchemaField( + description="Error message if the request failed", default="" + ) + + def __init__(self): + super().__init__( + id="89ccd99a-3c2b-4fbf-9e25-0ffa398d0314", + description="Update metadata for an existing Webset", + categories={BlockCategory.SEARCH}, + input_schema=ExaUpdateWebsetBlock.Input, + output_schema=ExaUpdateWebsetBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + url = f"https://api.exa.ai/websets/v0/websets/{input_data.webset_id}" + headers = { + "Content-Type": "application/json", + "x-api-key": credentials.api_key.get_secret_value(), + } + + # Build the payload + payload = {} + if input_data.metadata is not None: + payload["metadata"] = input_data.metadata + + try: + response = await Requests().post(url, headers=headers, json=payload) + data = response.json() + + yield "webset_id", data.get("id", "") + yield "status", data.get("status", "") + yield "external_id", data.get("externalId") + yield "metadata", data.get("metadata", {}) + yield "updated_at", data.get("updatedAt", "") + + except Exception as e: + yield "error", str(e) + yield "webset_id", "" + yield "status", "" + yield "metadata", {} + yield "updated_at", "" + + +class ExaListWebsetsBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput = exa.credentials_field( + description="The Exa integration requires an API Key." + ) + trigger: Any | None = SchemaField( + default=None, + description="Trigger for the webset, value is ignored!", + advanced=False, + ) + cursor: Optional[str] = SchemaField( + default=None, + description="Cursor for pagination through results", + advanced=True, + ) + limit: int = SchemaField( + default=25, + description="Number of websets to return (1-100)", + ge=1, + le=100, + advanced=True, + ) + + class Output(BlockSchema): + websets: list[Webset] = SchemaField( + description="List of websets", default_factory=list + ) + has_more: bool = SchemaField( + description="Whether there are more results to paginate through", + default=False, + ) + next_cursor: Optional[str] = SchemaField( + description="Cursor for the next page of results", default=None + ) + error: str = SchemaField( + description="Error message if the request failed", default="" + ) + + def __init__(self): + super().__init__( + id="1dcd8fd6-c13f-4e6f-bd4c-654428fa4757", + description="List all Websets with pagination support", + categories={BlockCategory.SEARCH}, + input_schema=ExaListWebsetsBlock.Input, + output_schema=ExaListWebsetsBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + url = "https://api.exa.ai/websets/v0/websets" + headers = { + "x-api-key": credentials.api_key.get_secret_value(), + } + + params: dict[str, Any] = { + "limit": input_data.limit, + } + if input_data.cursor: + params["cursor"] = input_data.cursor + + try: + response = await Requests().get(url, headers=headers, params=params) + data = response.json() + + yield "websets", data.get("data", []) + yield "has_more", data.get("hasMore", False) + yield "next_cursor", data.get("nextCursor") + + except Exception as e: + yield "error", str(e) + yield "websets", [] + yield "has_more", False + + +class ExaGetWebsetBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput = exa.credentials_field( + description="The Exa integration requires an API Key." + ) + webset_id: str = SchemaField( + description="The ID or external ID of the Webset to retrieve", + placeholder="webset-id-or-external-id", + ) + + class Output(BlockSchema): + webset_id: str = SchemaField(description="The unique identifier for the webset") + status: str = SchemaField(description="The status of the webset") + external_id: Optional[str] = SchemaField( + description="The external identifier for the webset", default=None + ) + searches: list[dict] = SchemaField( + description="The searches performed on the webset", default_factory=list + ) + enrichments: list[dict] = SchemaField( + description="The enrichments applied to the webset", default_factory=list + ) + monitors: list[dict] = SchemaField( + description="The monitors for the webset", default_factory=list + ) + items: Optional[list[dict]] = SchemaField( + description="The items in the webset (if expand_items is true)", + default=None, + ) + metadata: dict = SchemaField( + description="Key-value pairs associated with the webset", + default_factory=dict, + ) + created_at: str = SchemaField( + description="The date and time the webset was created" + ) + updated_at: str = SchemaField( + description="The date and time the webset was last updated" + ) + error: str = SchemaField( + description="Error message if the request failed", default="" + ) + + def __init__(self): + super().__init__( + id="6ab8e12a-132c-41bf-b5f3-d662620fa832", + description="Retrieve a Webset by ID or external ID", + categories={BlockCategory.SEARCH}, + input_schema=ExaGetWebsetBlock.Input, + output_schema=ExaGetWebsetBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + url = f"https://api.exa.ai/websets/v0/websets/{input_data.webset_id}" + headers = { + "x-api-key": credentials.api_key.get_secret_value(), + } + + try: + response = await Requests().get(url, headers=headers) + data = response.json() + + yield "webset_id", data.get("id", "") + yield "status", data.get("status", "") + yield "external_id", data.get("externalId") + yield "searches", data.get("searches", []) + yield "enrichments", data.get("enrichments", []) + yield "monitors", data.get("monitors", []) + yield "items", data.get("items") + yield "metadata", data.get("metadata", {}) + yield "created_at", data.get("createdAt", "") + yield "updated_at", data.get("updatedAt", "") + + except Exception as e: + yield "error", str(e) + yield "webset_id", "" + yield "status", "" + yield "searches", [] + yield "enrichments", [] + yield "monitors", [] + yield "metadata", {} + yield "created_at", "" + yield "updated_at", "" + + +class ExaDeleteWebsetBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput = exa.credentials_field( + description="The Exa integration requires an API Key." + ) + webset_id: str = SchemaField( + description="The ID or external ID of the Webset to delete", + placeholder="webset-id-or-external-id", + ) + + class Output(BlockSchema): + webset_id: str = SchemaField( + description="The unique identifier for the deleted webset" + ) + external_id: Optional[str] = SchemaField( + description="The external identifier for the deleted webset", default=None + ) + status: str = SchemaField(description="The status of the deleted webset") + success: str = SchemaField( + description="Whether the deletion was successful", default="true" + ) + error: str = SchemaField( + description="Error message if the request failed", default="" + ) + + def __init__(self): + super().__init__( + id="aa6994a2-e986-421f-8d4c-7671d3be7b7e", + description="Delete a Webset and all its items", + categories={BlockCategory.SEARCH}, + input_schema=ExaDeleteWebsetBlock.Input, + output_schema=ExaDeleteWebsetBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + url = f"https://api.exa.ai/websets/v0/websets/{input_data.webset_id}" + headers = { + "x-api-key": credentials.api_key.get_secret_value(), + } + + try: + response = await Requests().delete(url, headers=headers) + data = response.json() + + yield "webset_id", data.get("id", "") + yield "external_id", data.get("externalId") + yield "status", data.get("status", "") + yield "success", "true" + + except Exception as e: + yield "error", str(e) + yield "webset_id", "" + yield "status", "" + yield "success", "false" + + +class ExaCancelWebsetBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput = exa.credentials_field( + description="The Exa integration requires an API Key." + ) + webset_id: str = SchemaField( + description="The ID or external ID of the Webset to cancel", + placeholder="webset-id-or-external-id", + ) + + class Output(BlockSchema): + webset_id: str = SchemaField(description="The unique identifier for the webset") + status: str = SchemaField( + description="The status of the webset after cancellation" + ) + external_id: Optional[str] = SchemaField( + description="The external identifier for the webset", default=None + ) + success: str = SchemaField( + description="Whether the cancellation was successful", default="true" + ) + error: str = SchemaField( + description="Error message if the request failed", default="" + ) + + def __init__(self): + super().__init__( + id="e40a6420-1db8-47bb-b00a-0e6aecd74176", + description="Cancel all operations being performed on a Webset", + categories={BlockCategory.SEARCH}, + input_schema=ExaCancelWebsetBlock.Input, + output_schema=ExaCancelWebsetBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + url = f"https://api.exa.ai/websets/v0/websets/{input_data.webset_id}/cancel" + headers = { + "x-api-key": credentials.api_key.get_secret_value(), + } + + try: + response = await Requests().post(url, headers=headers) + data = response.json() + + yield "webset_id", data.get("id", "") + yield "status", data.get("status", "") + yield "external_id", data.get("externalId") + yield "success", "true" + + except Exception as e: + yield "error", str(e) + yield "webset_id", "" + yield "status", "" + yield "success", "false" diff --git a/autogpt_platform/backend/backend/blocks/fal/ai_video_generator.py b/autogpt_platform/backend/backend/blocks/fal/ai_video_generator.py index e52e2ba5b245..2e795f0d7833 100644 --- a/autogpt_platform/backend/backend/blocks/fal/ai_video_generator.py +++ b/autogpt_platform/backend/backend/blocks/fal/ai_video_generator.py @@ -1,10 +1,8 @@ +import asyncio import logging -import time from enum import Enum from typing import Any -import httpx - from backend.blocks.fal._auth import ( TEST_CREDENTIALS, TEST_CREDENTIALS_INPUT, @@ -14,6 +12,7 @@ ) from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField +from backend.util.request import ClientResponseError, Requests logger = logging.getLogger(__name__) @@ -21,6 +20,7 @@ class FalModel(str, Enum): MOCHI = "fal-ai/mochi-v1" LUMA = "fal-ai/luma-dream-machine" + VEO3 = "fal-ai/veo3" class AIVideoGeneratorBlock(Block): @@ -42,7 +42,7 @@ class Output(BlockSchema): description="Error message if video generation failed." ) logs: list[str] = SchemaField( - description="Generation progress logs.", optional=True + description="Generation progress logs.", ) def __init__(self): @@ -65,35 +65,37 @@ def __init__(self): ) def _get_headers(self, api_key: str) -> dict[str, str]: - """Get headers for FAL API requests.""" + """Get headers for FAL API Requests.""" return { "Authorization": f"Key {api_key}", "Content-Type": "application/json", } - def _submit_request( + async def _submit_request( self, url: str, headers: dict[str, str], data: dict[str, Any] ) -> dict[str, Any]: """Submit a request to the FAL API.""" try: - response = httpx.post(url, headers=headers, json=data) - response.raise_for_status() + response = await Requests().post(url, headers=headers, json=data) return response.json() - except httpx.HTTPError as e: + except ClientResponseError as e: logger.error(f"FAL API request failed: {str(e)}") raise RuntimeError(f"Failed to submit request: {str(e)}") - def _poll_status(self, status_url: str, headers: dict[str, str]) -> dict[str, Any]: + async def _poll_status( + self, status_url: str, headers: dict[str, str] + ) -> dict[str, Any]: """Poll the status endpoint until completion or failure.""" try: - response = httpx.get(status_url, headers=headers) - response.raise_for_status() + response = await Requests().get(status_url, headers=headers) return response.json() - except httpx.HTTPError as e: + except ClientResponseError as e: logger.error(f"Failed to get status: {str(e)}") raise RuntimeError(f"Failed to get status: {str(e)}") - def generate_video(self, input_data: Input, credentials: FalCredentials) -> str: + async def generate_video( + self, input_data: Input, credentials: FalCredentials + ) -> str: """Generate video using the specified FAL model.""" base_url = "https://queue.fal.run" api_key = credentials.api_key.get_secret_value() @@ -102,13 +104,16 @@ def generate_video(self, input_data: Input, credentials: FalCredentials) -> str: # Submit generation request submit_url = f"{base_url}/{input_data.model.value}" submit_data = {"prompt": input_data.prompt} + if input_data.model == FalModel.VEO3: + submit_data["generate_audio"] = True # type: ignore seen_logs = set() try: # Submit request to queue - submit_response = httpx.post(submit_url, headers=headers, json=submit_data) - submit_response.raise_for_status() + submit_response = await Requests().post( + submit_url, headers=headers, json=submit_data + ) request_data = submit_response.json() # Get request_id and urls from initial response @@ -119,14 +124,23 @@ def generate_video(self, input_data: Input, credentials: FalCredentials) -> str: if not all([request_id, status_url, result_url]): raise ValueError("Missing required data in submission response") + # Ensure status_url is a string + if not isinstance(status_url, str): + raise ValueError("Invalid status URL format") + + # Ensure result_url is a string + if not isinstance(result_url, str): + raise ValueError("Invalid result URL format") + # Poll for status with exponential backoff max_attempts = 30 attempt = 0 base_wait_time = 5 while attempt < max_attempts: - status_response = httpx.get(f"{status_url}?logs=1", headers=headers) - status_response.raise_for_status() + status_response = await Requests().get( + f"{status_url}?logs=1", headers=headers + ) status_data = status_response.json() # Process new logs only @@ -149,8 +163,7 @@ def generate_video(self, input_data: Input, credentials: FalCredentials) -> str: status = status_data.get("status") if status == "COMPLETED": # Get the final result - result_response = httpx.get(result_url, headers=headers) - result_response.raise_for_status() + result_response = await Requests().get(result_url, headers=headers) result_data = result_response.json() if "video" not in result_data or not isinstance( @@ -159,8 +172,8 @@ def generate_video(self, input_data: Input, credentials: FalCredentials) -> str: raise ValueError("Invalid response format - missing video data") video_url = result_data["video"].get("url") - if not video_url: - raise ValueError("No video URL in response") + if not video_url or not isinstance(video_url, str): + raise ValueError("No valid video URL in response") return video_url @@ -180,19 +193,19 @@ def generate_video(self, input_data: Input, credentials: FalCredentials) -> str: logger.info(f"[FAL Generation] Status: Unknown status: {status}") wait_time = min(base_wait_time * (2**attempt), 60) # Cap at 60 seconds - time.sleep(wait_time) + await asyncio.sleep(wait_time) attempt += 1 raise RuntimeError("Maximum polling attempts reached") - except httpx.HTTPError as e: + except ClientResponseError as e: raise RuntimeError(f"API request failed: {str(e)}") - def run( + async def run( self, input_data: Input, *, credentials: FalCredentials, **kwargs ) -> BlockOutput: try: - video_url = self.generate_video(input_data, credentials) + video_url = await self.generate_video(input_data, credentials) yield "video_url", video_url except Exception as e: error_message = str(e) diff --git a/autogpt_platform/backend/backend/blocks/firecrawl/__init__.py b/autogpt_platform/backend/backend/blocks/firecrawl/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/blocks/firecrawl/_api.py b/autogpt_platform/backend/backend/blocks/firecrawl/_api.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/blocks/firecrawl/_config.py b/autogpt_platform/backend/backend/blocks/firecrawl/_config.py new file mode 100644 index 000000000000..cc176c4a86cb --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/firecrawl/_config.py @@ -0,0 +1,8 @@ +from backend.sdk import BlockCostType, ProviderBuilder + +firecrawl = ( + ProviderBuilder("firecrawl") + .with_api_key("FIRECRAWL_API_KEY", "Firecrawl API Key") + .with_base_cost(1, BlockCostType.RUN) + .build() +) diff --git a/autogpt_platform/backend/backend/blocks/firecrawl/crawl.py b/autogpt_platform/backend/backend/blocks/firecrawl/crawl.py new file mode 100644 index 000000000000..6b8e1e0ad89b --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/firecrawl/crawl.py @@ -0,0 +1,114 @@ +from enum import Enum +from typing import Any + +from firecrawl import FirecrawlApp, ScrapeOptions + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, +) + +from ._config import firecrawl + + +class ScrapeFormat(Enum): + MARKDOWN = "markdown" + HTML = "html" + RAW_HTML = "rawHtml" + LINKS = "links" + SCREENSHOT = "screenshot" + SCREENSHOT_FULL_PAGE = "screenshot@fullPage" + JSON = "json" + CHANGE_TRACKING = "changeTracking" + + +class FirecrawlCrawlBlock(Block): + + class Input(BlockSchema): + credentials: CredentialsMetaInput = firecrawl.credentials_field() + url: str = SchemaField(description="The URL to crawl") + limit: int = SchemaField(description="The number of pages to crawl", default=10) + only_main_content: bool = SchemaField( + description="Only return the main content of the page excluding headers, navs, footers, etc.", + default=True, + ) + max_age: int = SchemaField( + description="The maximum age of the page in milliseconds - default is 1 hour", + default=3600000, + ) + wait_for: int = SchemaField( + description="Specify a delay in milliseconds before fetching the content, allowing the page sufficient time to load.", + default=0, + ) + formats: list[ScrapeFormat] = SchemaField( + description="The format of the crawl", default=[ScrapeFormat.MARKDOWN] + ) + + class Output(BlockSchema): + data: list[dict[str, Any]] = SchemaField(description="The result of the crawl") + markdown: str = SchemaField(description="The markdown of the crawl") + html: str = SchemaField(description="The html of the crawl") + raw_html: str = SchemaField(description="The raw html of the crawl") + links: list[str] = SchemaField(description="The links of the crawl") + screenshot: str = SchemaField(description="The screenshot of the crawl") + screenshot_full_page: str = SchemaField( + description="The screenshot full page of the crawl" + ) + json_data: dict[str, Any] = SchemaField( + description="The json data of the crawl" + ) + change_tracking: dict[str, Any] = SchemaField( + description="The change tracking of the crawl" + ) + + def __init__(self): + super().__init__( + id="bdbbaba0-03b7-4971-970e-699e2de6015e", + description="Firecrawl crawls websites to extract comprehensive data while bypassing blockers.", + categories={BlockCategory.SEARCH}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + + app = FirecrawlApp(api_key=credentials.api_key.get_secret_value()) + + # Sync call + crawl_result = app.crawl_url( + input_data.url, + limit=input_data.limit, + scrape_options=ScrapeOptions( + formats=[format.value for format in input_data.formats], + onlyMainContent=input_data.only_main_content, + maxAge=input_data.max_age, + waitFor=input_data.wait_for, + ), + ) + yield "data", crawl_result.data + + for data in crawl_result.data: + for f in input_data.formats: + if f == ScrapeFormat.MARKDOWN: + yield "markdown", data.markdown + elif f == ScrapeFormat.HTML: + yield "html", data.html + elif f == ScrapeFormat.RAW_HTML: + yield "raw_html", data.rawHtml + elif f == ScrapeFormat.LINKS: + yield "links", data.links + elif f == ScrapeFormat.SCREENSHOT: + yield "screenshot", data.screenshot + elif f == ScrapeFormat.SCREENSHOT_FULL_PAGE: + yield "screenshot_full_page", data.screenshot + elif f == ScrapeFormat.CHANGE_TRACKING: + yield "change_tracking", data.changeTracking + elif f == ScrapeFormat.JSON: + yield "json", data.json diff --git a/autogpt_platform/backend/backend/blocks/firecrawl/extract.py b/autogpt_platform/backend/backend/blocks/firecrawl/extract.py new file mode 100755 index 000000000000..8f3b507bec67 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/firecrawl/extract.py @@ -0,0 +1,66 @@ +from typing import Any + +from firecrawl import FirecrawlApp + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockCost, + BlockCostType, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, + cost, +) + +from ._config import firecrawl + + +@cost(BlockCost(2, BlockCostType.RUN)) +class FirecrawlExtractBlock(Block): + + class Input(BlockSchema): + credentials: CredentialsMetaInput = firecrawl.credentials_field() + urls: list[str] = SchemaField( + description="The URLs to crawl - at least one is required. Wildcards are supported. (/*)" + ) + prompt: str | None = SchemaField( + description="The prompt to use for the crawl", default=None, advanced=False + ) + output_schema: dict | None = SchemaField( + description="A Json Schema describing the output structure if more rigid structure is desired.", + default=None, + ) + enable_web_search: bool = SchemaField( + description="When true, extraction can follow links outside the specified domain.", + default=False, + ) + + class Output(BlockSchema): + data: dict[str, Any] = SchemaField(description="The result of the crawl") + + def __init__(self): + super().__init__( + id="d1774756-4d9e-40e6-bab1-47ec0ccd81b2", + description="Firecrawl crawls websites to extract comprehensive data while bypassing blockers.", + categories={BlockCategory.SEARCH}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + + app = FirecrawlApp(api_key=credentials.api_key.get_secret_value()) + + extract_result = app.extract( + urls=input_data.urls, + prompt=input_data.prompt, + schema=input_data.output_schema, + enable_web_search=input_data.enable_web_search, + ) + + yield "data", extract_result.data diff --git a/autogpt_platform/backend/backend/blocks/firecrawl/map.py b/autogpt_platform/backend/backend/blocks/firecrawl/map.py new file mode 100644 index 000000000000..7661377901b9 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/firecrawl/map.py @@ -0,0 +1,46 @@ +from firecrawl import FirecrawlApp + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, +) + +from ._config import firecrawl + + +class FirecrawlMapWebsiteBlock(Block): + + class Input(BlockSchema): + credentials: CredentialsMetaInput = firecrawl.credentials_field() + + url: str = SchemaField(description="The website url to map") + + class Output(BlockSchema): + links: list[str] = SchemaField(description="The links of the website") + + def __init__(self): + super().__init__( + id="f0f43e2b-c943-48a0-a7f1-40136ca4d3b9", + description="Firecrawl maps a website to extract all the links.", + categories={BlockCategory.SEARCH}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + + app = FirecrawlApp(api_key=credentials.api_key.get_secret_value()) + + # Sync call + map_result = app.map_url( + url=input_data.url, + ) + + yield "links", map_result.links diff --git a/autogpt_platform/backend/backend/blocks/firecrawl/scrape.py b/autogpt_platform/backend/backend/blocks/firecrawl/scrape.py new file mode 100644 index 000000000000..65627ad9545b --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/firecrawl/scrape.py @@ -0,0 +1,109 @@ +from enum import Enum +from typing import Any + +from firecrawl import FirecrawlApp + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, +) + +from ._config import firecrawl + + +class ScrapeFormat(Enum): + MARKDOWN = "markdown" + HTML = "html" + RAW_HTML = "rawHtml" + LINKS = "links" + SCREENSHOT = "screenshot" + SCREENSHOT_FULL_PAGE = "screenshot@fullPage" + JSON = "json" + CHANGE_TRACKING = "changeTracking" + + +class FirecrawlScrapeBlock(Block): + + class Input(BlockSchema): + credentials: CredentialsMetaInput = firecrawl.credentials_field() + url: str = SchemaField(description="The URL to crawl") + limit: int = SchemaField(description="The number of pages to crawl", default=10) + only_main_content: bool = SchemaField( + description="Only return the main content of the page excluding headers, navs, footers, etc.", + default=True, + ) + max_age: int = SchemaField( + description="The maximum age of the page in milliseconds - default is 1 hour", + default=3600000, + ) + wait_for: int = SchemaField( + description="Specify a delay in milliseconds before fetching the content, allowing the page sufficient time to load.", + default=200, + ) + formats: list[ScrapeFormat] = SchemaField( + description="The format of the crawl", default=[ScrapeFormat.MARKDOWN] + ) + + class Output(BlockSchema): + data: dict[str, Any] = SchemaField(description="The result of the crawl") + markdown: str = SchemaField(description="The markdown of the crawl") + html: str = SchemaField(description="The html of the crawl") + raw_html: str = SchemaField(description="The raw html of the crawl") + links: list[str] = SchemaField(description="The links of the crawl") + screenshot: str = SchemaField(description="The screenshot of the crawl") + screenshot_full_page: str = SchemaField( + description="The screenshot full page of the crawl" + ) + json_data: dict[str, Any] = SchemaField( + description="The json data of the crawl" + ) + change_tracking: dict[str, Any] = SchemaField( + description="The change tracking of the crawl" + ) + + def __init__(self): + super().__init__( + id="ac444320-cf5e-4697-b586-2604c17a3e75", + description="Firecrawl scrapes a website to extract comprehensive data while bypassing blockers.", + categories={BlockCategory.SEARCH}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + + app = FirecrawlApp(api_key=credentials.api_key.get_secret_value()) + + scrape_result = app.scrape_url( + input_data.url, + formats=[format.value for format in input_data.formats], + only_main_content=input_data.only_main_content, + max_age=input_data.max_age, + wait_for=input_data.wait_for, + ) + yield "data", scrape_result + + for f in input_data.formats: + if f == ScrapeFormat.MARKDOWN: + yield "markdown", scrape_result.markdown + elif f == ScrapeFormat.HTML: + yield "html", scrape_result.html + elif f == ScrapeFormat.RAW_HTML: + yield "raw_html", scrape_result.rawHtml + elif f == ScrapeFormat.LINKS: + yield "links", scrape_result.links + elif f == ScrapeFormat.SCREENSHOT: + yield "screenshot", scrape_result.screenshot + elif f == ScrapeFormat.SCREENSHOT_FULL_PAGE: + yield "screenshot_full_page", scrape_result.screenshot + elif f == ScrapeFormat.CHANGE_TRACKING: + yield "change_tracking", scrape_result.changeTracking + elif f == ScrapeFormat.JSON: + yield "json", scrape_result.json diff --git a/autogpt_platform/backend/backend/blocks/firecrawl/search.py b/autogpt_platform/backend/backend/blocks/firecrawl/search.py new file mode 100644 index 000000000000..521d0f7e35d5 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/firecrawl/search.py @@ -0,0 +1,79 @@ +from enum import Enum +from typing import Any + +from firecrawl import FirecrawlApp, ScrapeOptions + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, +) + +from ._config import firecrawl + + +class ScrapeFormat(Enum): + MARKDOWN = "markdown" + HTML = "html" + RAW_HTML = "rawHtml" + LINKS = "links" + SCREENSHOT = "screenshot" + SCREENSHOT_FULL_PAGE = "screenshot@fullPage" + JSON = "json" + CHANGE_TRACKING = "changeTracking" + + +class FirecrawlSearchBlock(Block): + + class Input(BlockSchema): + credentials: CredentialsMetaInput = firecrawl.credentials_field() + query: str = SchemaField(description="The query to search for") + limit: int = SchemaField(description="The number of pages to crawl", default=10) + max_age: int = SchemaField( + description="The maximum age of the page in milliseconds - default is 1 hour", + default=3600000, + ) + wait_for: int = SchemaField( + description="Specify a delay in milliseconds before fetching the content, allowing the page sufficient time to load.", + default=200, + ) + formats: list[ScrapeFormat] = SchemaField( + description="Returns the content of the search if specified", default=[] + ) + + class Output(BlockSchema): + data: dict[str, Any] = SchemaField(description="The result of the search") + site: dict[str, Any] = SchemaField(description="The site of the search") + + def __init__(self): + super().__init__( + id="f8d2f28d-b3a1-405b-804e-418c087d288b", + description="Firecrawl searches the web for the given query.", + categories={BlockCategory.SEARCH}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + + app = FirecrawlApp(api_key=credentials.api_key.get_secret_value()) + + # Sync call + scrape_result = app.search( + input_data.query, + limit=input_data.limit, + scrape_options=ScrapeOptions( + formats=[format.value for format in input_data.formats], + maxAge=input_data.max_age, + waitFor=input_data.wait_for, + ), + ) + yield "data", scrape_result + for site in scrape_result.data: + yield "site", site diff --git a/autogpt_platform/backend/backend/blocks/flux_kontext.py b/autogpt_platform/backend/backend/blocks/flux_kontext.py new file mode 100644 index 000000000000..e3729240fa5e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/flux_kontext.py @@ -0,0 +1,185 @@ +from enum import Enum +from typing import Literal, Optional + +from pydantic import SecretStr +from replicate.client import Client as ReplicateClient +from replicate.helpers import FileOutput + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import ( + APIKeyCredentials, + CredentialsField, + CredentialsMetaInput, + SchemaField, +) +from backend.integrations.providers import ProviderName +from backend.util.file import MediaFileType, store_media_file + +TEST_CREDENTIALS = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="replicate", + api_key=SecretStr("mock-replicate-api-key"), + title="Mock Replicate API key", + expires_at=None, +) +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.type, +} + + +class FluxKontextModelName(str, Enum): + PRO = "Flux Kontext Pro" + MAX = "Flux Kontext Max" + + @property + def api_name(self) -> str: + return f"black-forest-labs/flux-kontext-{self.name.lower()}" + + +class AspectRatio(str, Enum): + MATCH_INPUT_IMAGE = "match_input_image" + ASPECT_1_1 = "1:1" + ASPECT_16_9 = "16:9" + ASPECT_9_16 = "9:16" + ASPECT_4_3 = "4:3" + ASPECT_3_4 = "3:4" + ASPECT_3_2 = "3:2" + ASPECT_2_3 = "2:3" + ASPECT_4_5 = "4:5" + ASPECT_5_4 = "5:4" + ASPECT_21_9 = "21:9" + ASPECT_9_21 = "9:21" + ASPECT_2_1 = "2:1" + ASPECT_1_2 = "1:2" + + +class AIImageEditorBlock(Block): + class Input(BlockSchema): + credentials: CredentialsMetaInput[ + Literal[ProviderName.REPLICATE], Literal["api_key"] + ] = CredentialsField( + description="Replicate API key with permissions for Flux Kontext models", + ) + prompt: str = SchemaField( + description="Text instruction describing the desired edit", + title="Prompt", + ) + input_image: Optional[MediaFileType] = SchemaField( + description="Reference image URI (jpeg, png, gif, webp)", + default=None, + title="Input Image", + ) + aspect_ratio: AspectRatio = SchemaField( + description="Aspect ratio of the generated image", + default=AspectRatio.MATCH_INPUT_IMAGE, + title="Aspect Ratio", + advanced=False, + ) + seed: Optional[int] = SchemaField( + description="Random seed. Set for reproducible generation", + default=None, + title="Seed", + advanced=True, + ) + model: FluxKontextModelName = SchemaField( + description="Model variant to use", + default=FluxKontextModelName.PRO, + title="Model", + ) + + class Output(BlockSchema): + output_image: MediaFileType = SchemaField( + description="URL of the transformed image" + ) + error: str = SchemaField(description="Error message if generation failed") + + def __init__(self): + super().__init__( + id="3fd9c73d-4370-4925-a1ff-1b86b99fabfa", + description=( + "Edit images using BlackForest Labs' Flux Kontext models. Provide a prompt " + "and optional reference image to generate a modified image." + ), + categories={BlockCategory.AI, BlockCategory.MULTIMEDIA}, + input_schema=AIImageEditorBlock.Input, + output_schema=AIImageEditorBlock.Output, + test_input={ + "prompt": "Add a hat to the cat", + "input_image": "data:image/png;base64,MQ==", + "aspect_ratio": AspectRatio.MATCH_INPUT_IMAGE, + "seed": None, + "model": FluxKontextModelName.PRO, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("output_image", "https://replicate.com/output/edited-image.png"), + ], + test_mock={ + "run_model": lambda *args, **kwargs: "https://replicate.com/output/edited-image.png", + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + result = await self.run_model( + api_key=credentials.api_key, + model_name=input_data.model.api_name, + prompt=input_data.prompt, + input_image_b64=( + await store_media_file( + graph_exec_id=graph_exec_id, + file=input_data.input_image, + user_id=user_id, + return_content=True, + ) + if input_data.input_image + else None + ), + aspect_ratio=input_data.aspect_ratio.value, + seed=input_data.seed, + ) + yield "output_image", result + + async def run_model( + self, + api_key: SecretStr, + model_name: str, + prompt: str, + input_image_b64: Optional[str], + aspect_ratio: str, + seed: Optional[int], + ) -> MediaFileType: + client = ReplicateClient(api_token=api_key.get_secret_value()) + input_params = { + "prompt": prompt, + "input_image": input_image_b64, + "aspect_ratio": aspect_ratio, + **({"seed": seed} if seed is not None else {}), + } + + output: FileOutput | list[FileOutput] = await client.async_run( # type: ignore + model_name, + input=input_params, + wait=False, + ) + + if isinstance(output, list) and output: + output = output[0] + + if isinstance(output, FileOutput): + return MediaFileType(output.url) + if isinstance(output, str): + return MediaFileType(output) + + raise ValueError("No output received") diff --git a/autogpt_platform/backend/backend/blocks/generic_webhook/__init__.py b/autogpt_platform/backend/backend/blocks/generic_webhook/__init__.py new file mode 100644 index 000000000000..2a38bb0765c4 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/generic_webhook/__init__.py @@ -0,0 +1,9 @@ +# Import the provider builder to ensure it's registered +from backend.sdk.registry import AutoRegistry + +from .triggers import GenericWebhookTriggerBlock, generic_webhook + +# Ensure the SDK registry is patched to include our webhook manager +AutoRegistry.patch_integrations() + +__all__ = ["GenericWebhookTriggerBlock", "generic_webhook"] diff --git a/autogpt_platform/backend/backend/blocks/generic_webhook/_webhook.py b/autogpt_platform/backend/backend/blocks/generic_webhook/_webhook.py new file mode 100644 index 000000000000..9dd4b8c2c7ca --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/generic_webhook/_webhook.py @@ -0,0 +1,25 @@ +import logging + +from fastapi import Request +from strenum import StrEnum + +from backend.sdk import Credentials, ManualWebhookManagerBase, Webhook + +logger = logging.getLogger(__name__) + + +class GenericWebhookType(StrEnum): + PLAIN = "plain" + + +class GenericWebhooksManager(ManualWebhookManagerBase): + WebhookType = GenericWebhookType + + @classmethod + async def validate_payload( + cls, webhook: Webhook, request: Request, credentials: Credentials | None = None + ) -> tuple[dict, str]: + payload = await request.json() + event_type = GenericWebhookType.PLAIN + + return payload, event_type diff --git a/autogpt_platform/backend/backend/blocks/generic_webhook/triggers.py b/autogpt_platform/backend/backend/blocks/generic_webhook/triggers.py new file mode 100644 index 000000000000..fdddeb14371f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/generic_webhook/triggers.py @@ -0,0 +1,59 @@ +from backend.sdk import ( + Block, + BlockCategory, + BlockManualWebhookConfig, + BlockOutput, + BlockSchema, + ProviderBuilder, + ProviderName, + SchemaField, +) + +from ._webhook import GenericWebhooksManager, GenericWebhookType + +generic_webhook = ( + ProviderBuilder("generic_webhook") + .with_webhook_manager(GenericWebhooksManager) + .build() +) + + +class GenericWebhookTriggerBlock(Block): + class Input(BlockSchema): + payload: dict = SchemaField(hidden=True, default_factory=dict) + constants: dict = SchemaField( + description="The constants to be set when the block is put on the graph", + default_factory=dict, + ) + + class Output(BlockSchema): + payload: dict = SchemaField( + description="The complete webhook payload that was received from the generic webhook." + ) + constants: dict = SchemaField( + description="The constants to be set when the block is put on the graph" + ) + + example_payload = {"message": "Hello, World!"} + + def __init__(self): + super().__init__( + id="8fa8c167-2002-47ce-aba8-97572fc5d387", + description="This block will output the contents of the generic input for the webhook.", + categories={BlockCategory.INPUT}, + input_schema=GenericWebhookTriggerBlock.Input, + output_schema=GenericWebhookTriggerBlock.Output, + webhook_config=BlockManualWebhookConfig( + provider=ProviderName(generic_webhook.name), + webhook_type=GenericWebhookType.PLAIN, + ), + test_input={"constants": {"key": "value"}, "payload": self.example_payload}, + test_output=[ + ("constants", {"key": "value"}), + ("payload", self.example_payload), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + yield "constants", input_data.constants + yield "payload", input_data.payload diff --git a/autogpt_platform/backend/backend/blocks/github/_api.py b/autogpt_platform/backend/backend/blocks/github/_api.py index 6ec91eeb37db..03e4926673ba 100644 --- a/autogpt_platform/backend/backend/blocks/github/_api.py +++ b/autogpt_platform/backend/backend/blocks/github/_api.py @@ -1,16 +1,30 @@ +from typing import overload from urllib.parse import urlparse -from backend.blocks.github._auth import GithubCredentials -from backend.util.request import Requests +from backend.blocks.github._auth import ( + GithubCredentials, + GithubFineGrainedAPICredentials, +) +from backend.util.request import URL, Requests -def _convert_to_api_url(url: str) -> str: +@overload +def _convert_to_api_url(url: str) -> str: ... + + +@overload +def _convert_to_api_url(url: URL) -> URL: ... + + +def _convert_to_api_url(url: str | URL) -> str | URL: """ Converts a standard GitHub URL to the corresponding GitHub API URL. Handles repository URLs, issue URLs, pull request URLs, and more. """ - parsed_url = urlparse(url) - path_parts = parsed_url.path.strip("/").split("/") + if url_as_str := isinstance(url, str): + url = urlparse(url) + + path_parts = url.path.strip("/").split("/") if len(path_parts) >= 2: owner, repo = path_parts[0], path_parts[1] @@ -25,17 +39,73 @@ def _convert_to_api_url(url: str) -> str: else: raise ValueError("Invalid GitHub URL format.") - return api_url + return api_url if url_as_str else urlparse(api_url) def _get_headers(credentials: GithubCredentials) -> dict[str, str]: return { - "Authorization": credentials.bearer(), + "Authorization": credentials.auth_header(), "Accept": "application/vnd.github.v3+json", } -def get_api(credentials: GithubCredentials, convert_urls: bool = True) -> Requests: +def convert_comment_url_to_api_endpoint(comment_url: str) -> str: + """ + Converts a GitHub comment URL (web interface) to the appropriate API endpoint URL. + + Handles: + 1. Issue/PR comments: #issuecomment-{id} + 2. PR review comments: #discussion_r{id} + + Returns the appropriate API endpoint path for the comment. + """ + # First, check if this is already an API URL + parsed_url = urlparse(comment_url) + if parsed_url.hostname == "api.github.com": + return comment_url + + # Replace pull with issues for comment endpoints + if "/pull/" in comment_url: + comment_url = comment_url.replace("/pull/", "/issues/") + + # Handle issue/PR comments (#issuecomment-xxx) + if "#issuecomment-" in comment_url: + base_url, comment_part = comment_url.split("#issuecomment-") + comment_id = comment_part + + # Extract repo information from base URL + parsed_url = urlparse(base_url) + path_parts = parsed_url.path.strip("/").split("/") + owner, repo = path_parts[0], path_parts[1] + + # Construct API URL for issue comments + return ( + f"https://api.github.com/repos/{owner}/{repo}/issues/comments/{comment_id}" + ) + + # Handle PR review comments (#discussion_r) + elif "#discussion_r" in comment_url: + base_url, comment_part = comment_url.split("#discussion_r") + comment_id = comment_part + + # Extract repo information from base URL + parsed_url = urlparse(base_url) + path_parts = parsed_url.path.strip("/").split("/") + owner, repo = path_parts[0], path_parts[1] + + # Construct API URL for PR review comments + return ( + f"https://api.github.com/repos/{owner}/{repo}/pulls/comments/{comment_id}" + ) + + # If no specific comment identifiers are found, use the general URL conversion + return _convert_to_api_url(comment_url) + + +def get_api( + credentials: GithubCredentials | GithubFineGrainedAPICredentials, + convert_urls: bool = True, +) -> Requests: return Requests( trusted_origins=["https://api.github.com", "https://github.com"], extra_url_validator=_convert_to_api_url if convert_urls else None, diff --git a/autogpt_platform/backend/backend/blocks/github/_auth.py b/autogpt_platform/backend/backend/blocks/github/_auth.py index df7eed90f701..3109024abf0e 100644 --- a/autogpt_platform/backend/backend/blocks/github/_auth.py +++ b/autogpt_platform/backend/backend/blocks/github/_auth.py @@ -22,6 +22,11 @@ Literal["api_key", "oauth2"] if GITHUB_OAUTH_IS_CONFIGURED else Literal["api_key"], ] +GithubFineGrainedAPICredentials = APIKeyCredentials +GithubFineGrainedAPICredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.GITHUB], Literal["api_key"] +] + def GithubCredentialsField(scope: str) -> GithubCredentialsInput: """ @@ -37,6 +42,16 @@ def GithubCredentialsField(scope: str) -> GithubCredentialsInput: ) +def GithubFineGrainedAPICredentialsField( + scope: str, +) -> GithubFineGrainedAPICredentialsInput: + return CredentialsField( + required_scopes={scope}, + description="The GitHub integration can be used with OAuth, " + "or any API key with sufficient permissions for the blocks it is used on.", + ) + + TEST_CREDENTIALS = APIKeyCredentials( id="01234567-89ab-cdef-0123-456789abcdef", provider="github", @@ -50,3 +65,18 @@ def GithubCredentialsField(scope: str) -> GithubCredentialsInput: "type": TEST_CREDENTIALS.type, "title": TEST_CREDENTIALS.type, } + +TEST_FINE_GRAINED_CREDENTIALS = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="github", + api_key=SecretStr("mock-github-api-key"), + title="Mock GitHub API key", + expires_at=None, +) + +TEST_FINE_GRAINED_CREDENTIALS_INPUT = { + "provider": TEST_FINE_GRAINED_CREDENTIALS.provider, + "id": TEST_FINE_GRAINED_CREDENTIALS.id, + "type": TEST_FINE_GRAINED_CREDENTIALS.type, + "title": TEST_FINE_GRAINED_CREDENTIALS.type, +} diff --git a/autogpt_platform/backend/backend/blocks/github/checks.py b/autogpt_platform/backend/backend/blocks/github/checks.py new file mode 100644 index 000000000000..9b9aecdf0736 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/github/checks.py @@ -0,0 +1,360 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + +from ._api import get_api +from ._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + GithubCredentials, + GithubCredentialsField, + GithubCredentialsInput, +) + + +# queued, in_progress, completed, waiting, requested, pending +class ChecksStatus(Enum): + QUEUED = "queued" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + WAITING = "waiting" + REQUESTED = "requested" + PENDING = "pending" + + +class ChecksConclusion(Enum): + SUCCESS = "success" + FAILURE = "failure" + NEUTRAL = "neutral" + CANCELLED = "cancelled" + TIMED_OUT = "timed_out" + ACTION_REQUIRED = "action_required" + SKIPPED = "skipped" + + +class GithubCreateCheckRunBlock(Block): + """Block for creating a new check run on a GitHub repository.""" + + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo:status") + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + name: str = SchemaField( + description="The name of the check run (e.g., 'code-coverage')", + ) + head_sha: str = SchemaField( + description="The SHA of the commit to check", + ) + status: ChecksStatus = SchemaField( + description="Current status of the check run", + default=ChecksStatus.QUEUED, + ) + conclusion: Optional[ChecksConclusion] = SchemaField( + description="The final conclusion of the check (required if status is completed)", + default=None, + ) + details_url: str = SchemaField( + description="The URL for the full details of the check", + default="", + ) + output_title: str = SchemaField( + description="Title of the check run output", + default="", + ) + output_summary: str = SchemaField( + description="Summary of the check run output", + default="", + ) + output_text: str = SchemaField( + description="Detailed text of the check run output", + default="", + ) + + class Output(BlockSchema): + class CheckRunResult(BaseModel): + id: int + html_url: str + status: str + + check_run: CheckRunResult = SchemaField( + description="Details of the created check run" + ) + error: str = SchemaField( + description="Error message if check run creation failed" + ) + + def __init__(self): + super().__init__( + id="2f45e89a-3b7d-4f22-b89e-6c4f5c7e1234", + description="Creates a new check run for a specific commit in a GitHub repository", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubCreateCheckRunBlock.Input, + output_schema=GithubCreateCheckRunBlock.Output, + test_input={ + "repo_url": "https://github.com/owner/repo", + "name": "test-check", + "head_sha": "ce587453ced02b1526dfb4cb910479d431683101", + "status": ChecksStatus.COMPLETED.value, + "conclusion": ChecksConclusion.SUCCESS.value, + "output_title": "Test Results", + "output_summary": "All tests passed", + "credentials": TEST_CREDENTIALS_INPUT, + }, + # requires a github app not available to oauth in our current system + disabled=True, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "check_run", + { + "id": 4, + "html_url": "https://github.com/owner/repo/runs/4", + "status": "completed", + }, + ), + ], + test_mock={ + "create_check_run": lambda *args, **kwargs: { + "id": 4, + "html_url": "https://github.com/owner/repo/runs/4", + "status": "completed", + } + }, + ) + + @staticmethod + async def create_check_run( + credentials: GithubCredentials, + repo_url: str, + name: str, + head_sha: str, + status: ChecksStatus, + conclusion: Optional[ChecksConclusion] = None, + details_url: Optional[str] = None, + output_title: Optional[str] = None, + output_summary: Optional[str] = None, + output_text: Optional[str] = None, + ) -> dict: + api = get_api(credentials) + + class CheckRunData(BaseModel): + name: str + head_sha: str + status: str + conclusion: Optional[str] = None + details_url: Optional[str] = None + output: Optional[dict[str, str]] = None + + data = CheckRunData( + name=name, + head_sha=head_sha, + status=status.value, + ) + + if conclusion: + data.conclusion = conclusion.value + + if details_url: + data.details_url = details_url + + if output_title or output_summary or output_text: + output_data = { + "title": output_title or "", + "summary": output_summary or "", + "text": output_text or "", + } + data.output = output_data + + check_runs_url = f"{repo_url}/check-runs" + response = await api.post( + check_runs_url, data=data.model_dump_json(exclude_none=True) + ) + result = response.json() + + return { + "id": result["id"], + "html_url": result["html_url"], + "status": result["status"], + } + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + result = await self.create_check_run( + credentials=credentials, + repo_url=input_data.repo_url, + name=input_data.name, + head_sha=input_data.head_sha, + status=input_data.status, + conclusion=input_data.conclusion, + details_url=input_data.details_url, + output_title=input_data.output_title, + output_summary=input_data.output_summary, + output_text=input_data.output_text, + ) + yield "check_run", result + except Exception as e: + yield "error", str(e) + + +class GithubUpdateCheckRunBlock(Block): + """Block for updating an existing check run on a GitHub repository.""" + + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo:status") + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + check_run_id: int = SchemaField( + description="The ID of the check run to update", + ) + status: ChecksStatus = SchemaField( + description="New status of the check run", + ) + conclusion: ChecksConclusion = SchemaField( + description="The final conclusion of the check (required if status is completed)", + ) + output_title: Optional[str] = SchemaField( + description="New title of the check run output", + default=None, + ) + output_summary: Optional[str] = SchemaField( + description="New summary of the check run output", + default=None, + ) + output_text: Optional[str] = SchemaField( + description="New detailed text of the check run output", + default=None, + ) + + class Output(BlockSchema): + class CheckRunResult(BaseModel): + id: int + html_url: str + status: str + conclusion: Optional[str] + + check_run: CheckRunResult = SchemaField( + description="Details of the updated check run" + ) + error: str = SchemaField(description="Error message if check run update failed") + + def __init__(self): + super().__init__( + id="8a23c567-9d01-4e56-b789-0c12d3e45678", # Generated UUID + description="Updates an existing check run in a GitHub repository", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubUpdateCheckRunBlock.Input, + output_schema=GithubUpdateCheckRunBlock.Output, + # requires a github app not available to oauth in our current system + disabled=True, + test_input={ + "repo_url": "https://github.com/owner/repo", + "check_run_id": 4, + "status": ChecksStatus.COMPLETED.value, + "conclusion": ChecksConclusion.SUCCESS.value, + "output_title": "Updated Results", + "output_summary": "All tests passed after retry", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "check_run", + { + "id": 4, + "html_url": "https://github.com/owner/repo/runs/4", + "status": "completed", + "conclusion": "success", + }, + ), + ], + test_mock={ + "update_check_run": lambda *args, **kwargs: { + "id": 4, + "html_url": "https://github.com/owner/repo/runs/4", + "status": "completed", + "conclusion": "success", + } + }, + ) + + @staticmethod + async def update_check_run( + credentials: GithubCredentials, + repo_url: str, + check_run_id: int, + status: ChecksStatus, + conclusion: Optional[ChecksConclusion] = None, + output_title: Optional[str] = None, + output_summary: Optional[str] = None, + output_text: Optional[str] = None, + ) -> dict: + api = get_api(credentials) + + class UpdateCheckRunData(BaseModel): + status: str + conclusion: Optional[str] = None + output: Optional[dict[str, str]] = None + + data = UpdateCheckRunData( + status=status.value, + ) + + if conclusion: + data.conclusion = conclusion.value + + if output_title or output_summary or output_text: + output_data = { + "title": output_title or "", + "summary": output_summary or "", + "text": output_text or "", + } + data.output = output_data + + check_run_url = f"{repo_url}/check-runs/{check_run_id}" + response = await api.patch( + check_run_url, data=data.model_dump_json(exclude_none=True) + ) + result = response.json() + + return { + "id": result["id"], + "html_url": result["html_url"], + "status": result["status"], + "conclusion": result.get("conclusion"), + } + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + result = await self.update_check_run( + credentials=credentials, + repo_url=input_data.repo_url, + check_run_id=input_data.check_run_id, + status=input_data.status, + conclusion=input_data.conclusion, + output_title=input_data.output_title, + output_summary=input_data.output_summary, + output_text=input_data.output_text, + ) + yield "check_run", result + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/github/ci.py b/autogpt_platform/backend/backend/blocks/github/ci.py new file mode 100644 index 000000000000..25adc04202d5 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/github/ci.py @@ -0,0 +1,388 @@ +import logging +import re +from enum import Enum +from typing import Optional + +from typing_extensions import TypedDict + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + +from ._api import get_api +from ._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + GithubCredentials, + GithubCredentialsField, + GithubCredentialsInput, +) + +logger = logging.getLogger(__name__) + + +class CheckRunStatus(Enum): + QUEUED = "queued" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + + +class CheckRunConclusion(Enum): + SUCCESS = "success" + FAILURE = "failure" + NEUTRAL = "neutral" + CANCELLED = "cancelled" + SKIPPED = "skipped" + TIMED_OUT = "timed_out" + ACTION_REQUIRED = "action_required" + + +class GithubGetCIResultsBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo: str = SchemaField( + description="GitHub repository", + placeholder="owner/repo", + ) + target: str | int = SchemaField( + description="Commit SHA or PR number to get CI results for", + placeholder="abc123def or 123", + ) + search_pattern: Optional[str] = SchemaField( + description="Optional regex pattern to search for in CI logs (e.g., error messages, file names)", + placeholder=".*error.*|.*warning.*", + default=None, + advanced=True, + ) + check_name_filter: Optional[str] = SchemaField( + description="Optional filter for specific check names (supports wildcards)", + placeholder="*lint* or build-*", + default=None, + advanced=True, + ) + + class Output(BlockSchema): + class CheckRunItem(TypedDict, total=False): + id: int + name: str + status: str + conclusion: Optional[str] + started_at: Optional[str] + completed_at: Optional[str] + html_url: str + details_url: Optional[str] + output_title: Optional[str] + output_summary: Optional[str] + output_text: Optional[str] + annotations: list[dict] + + class MatchedLine(TypedDict): + check_name: str + line_number: int + line: str + context: list[str] + + check_run: CheckRunItem = SchemaField( + title="Check Run", + description="Individual CI check run with details", + ) + check_runs: list[CheckRunItem] = SchemaField( + description="List of all CI check runs" + ) + matched_line: MatchedLine = SchemaField( + title="Matched Line", + description="Line matching the search pattern with context", + ) + matched_lines: list[MatchedLine] = SchemaField( + description="All lines matching the search pattern across all checks" + ) + overall_status: str = SchemaField( + description="Overall CI status (pending, success, failure)" + ) + overall_conclusion: str = SchemaField( + description="Overall CI conclusion if completed" + ) + total_checks: int = SchemaField(description="Total number of CI checks") + passed_checks: int = SchemaField(description="Number of passed checks") + failed_checks: int = SchemaField(description="Number of failed checks") + error: str = SchemaField(description="Error message if the operation failed") + + def __init__(self): + super().__init__( + id="8ad9e103-78f2-4fdb-ba12-3571f2c95e98", + description="This block gets CI results for a commit or PR, with optional search for specific errors/warnings in logs.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubGetCIResultsBlock.Input, + output_schema=GithubGetCIResultsBlock.Output, + test_input={ + "repo": "owner/repo", + "target": "abc123def456", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("overall_status", "completed"), + ("overall_conclusion", "success"), + ("total_checks", 1), + ("passed_checks", 1), + ("failed_checks", 0), + ( + "check_runs", + [ + { + "id": 123456, + "name": "build", + "status": "completed", + "conclusion": "success", + "started_at": "2024-01-01T00:00:00Z", + "completed_at": "2024-01-01T00:05:00Z", + "html_url": "https://github.com/owner/repo/runs/123456", + "details_url": None, + "output_title": "Build passed", + "output_summary": "All tests passed", + "output_text": "Build log output...", + "annotations": [], + } + ], + ), + ], + test_mock={ + "get_ci_results": lambda *args, **kwargs: { + "check_runs": [ + { + "id": 123456, + "name": "build", + "status": "completed", + "conclusion": "success", + "started_at": "2024-01-01T00:00:00Z", + "completed_at": "2024-01-01T00:05:00Z", + "html_url": "https://github.com/owner/repo/runs/123456", + "details_url": None, + "output_title": "Build passed", + "output_summary": "All tests passed", + "output_text": "Build log output...", + "annotations": [], + } + ], + "total_count": 1, + } + }, + ) + + @staticmethod + async def get_commit_sha(api, repo: str, target: str | int) -> str: + """Get commit SHA from either a commit SHA or PR URL.""" + # If it's already a SHA, return it + + if isinstance(target, str): + if re.match(r"^[0-9a-f]{6,40}$", target, re.IGNORECASE): + return target + + # If it's a PR URL, get the head SHA + if isinstance(target, int): + pr_url = f"https://api.github.com/repos/{repo}/pulls/{target}" + response = await api.get(pr_url) + pr_data = response.json() + return pr_data["head"]["sha"] + + raise ValueError("Target must be a commit SHA or PR URL") + + @staticmethod + async def search_in_logs( + check_runs: list, + pattern: str, + ) -> list[Output.MatchedLine]: + """Search for pattern in check run logs.""" + if not pattern: + return [] + + matched_lines = [] + regex = re.compile(pattern, re.IGNORECASE | re.MULTILINE) + + for check in check_runs: + output_text = check.get("output_text", "") or "" + if not output_text: + continue + + lines = output_text.split("\n") + for i, line in enumerate(lines): + if regex.search(line): + # Get context (2 lines before and after) + start = max(0, i - 2) + end = min(len(lines), i + 3) + context = lines[start:end] + + matched_lines.append( + { + "check_name": check["name"], + "line_number": i + 1, + "line": line, + "context": context, + } + ) + + return matched_lines + + @staticmethod + async def get_ci_results( + credentials: GithubCredentials, + repo: str, + target: str | int, + search_pattern: Optional[str] = None, + check_name_filter: Optional[str] = None, + ) -> dict: + api = get_api(credentials, convert_urls=False) + + # Get the commit SHA + commit_sha = await GithubGetCIResultsBlock.get_commit_sha(api, repo, target) + + # Get check runs for the commit + check_runs_url = ( + f"https://api.github.com/repos/{repo}/commits/{commit_sha}/check-runs" + ) + + # Get all pages of check runs + all_check_runs = [] + page = 1 + per_page = 100 + + while True: + response = await api.get( + check_runs_url, params={"per_page": per_page, "page": page} + ) + data = response.json() + + check_runs = data.get("check_runs", []) + all_check_runs.extend(check_runs) + + if len(check_runs) < per_page: + break + page += 1 + + # Filter by check name if specified + if check_name_filter: + import fnmatch + + filtered_runs = [] + for run in all_check_runs: + if fnmatch.fnmatch(run["name"].lower(), check_name_filter.lower()): + filtered_runs.append(run) + all_check_runs = filtered_runs + + # Get check run details with logs + detailed_runs = [] + for run in all_check_runs: + # Get detailed output including logs + if run.get("output", {}).get("text"): + # Already has output + detailed_run = { + "id": run["id"], + "name": run["name"], + "status": run["status"], + "conclusion": run.get("conclusion"), + "started_at": run.get("started_at"), + "completed_at": run.get("completed_at"), + "html_url": run["html_url"], + "details_url": run.get("details_url"), + "output_title": run.get("output", {}).get("title"), + "output_summary": run.get("output", {}).get("summary"), + "output_text": run.get("output", {}).get("text"), + "annotations": [], + } + else: + # Try to get logs from the check run + detailed_run = { + "id": run["id"], + "name": run["name"], + "status": run["status"], + "conclusion": run.get("conclusion"), + "started_at": run.get("started_at"), + "completed_at": run.get("completed_at"), + "html_url": run["html_url"], + "details_url": run.get("details_url"), + "output_title": run.get("output", {}).get("title"), + "output_summary": run.get("output", {}).get("summary"), + "output_text": None, + "annotations": [], + } + + # Get annotations if available + if run.get("output", {}).get("annotations_count", 0) > 0: + annotations_url = f"https://api.github.com/repos/{repo}/check-runs/{run['id']}/annotations" + try: + ann_response = await api.get(annotations_url) + detailed_run["annotations"] = ann_response.json() + except Exception: + pass + + detailed_runs.append(detailed_run) + + return { + "check_runs": detailed_runs, + "total_count": len(detailed_runs), + } + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + + try: + target = int(input_data.target) + except ValueError: + target = input_data.target + + result = await self.get_ci_results( + credentials, + input_data.repo, + target, + input_data.search_pattern, + input_data.check_name_filter, + ) + + check_runs = result["check_runs"] + + # Calculate overall status + if not check_runs: + yield "overall_status", "no_checks" + yield "overall_conclusion", "no_checks" + else: + all_completed = all(run["status"] == "completed" for run in check_runs) + if all_completed: + yield "overall_status", "completed" + # Determine overall conclusion + has_failure = any( + run["conclusion"] in ["failure", "timed_out", "action_required"] + for run in check_runs + ) + if has_failure: + yield "overall_conclusion", "failure" + else: + yield "overall_conclusion", "success" + else: + yield "overall_status", "pending" + yield "overall_conclusion", "pending" + + # Count checks + total = len(check_runs) + passed = sum(1 for run in check_runs if run.get("conclusion") == "success") + failed = sum( + 1 for run in check_runs if run.get("conclusion") in ["failure", "timed_out"] + ) + + yield "total_checks", total + yield "passed_checks", passed + yield "failed_checks", failed + + # Output check runs + yield "check_runs", check_runs + + # Search for patterns if specified + if input_data.search_pattern: + matched_lines = await self.search_in_logs( + check_runs, input_data.search_pattern + ) + if matched_lines: + yield "matched_lines", matched_lines diff --git a/autogpt_platform/backend/backend/blocks/github/issues.py b/autogpt_platform/backend/backend/blocks/github/issues.py index ff1d0a41ad0e..29766cabc736 100644 --- a/autogpt_platform/backend/backend/blocks/github/issues.py +++ b/autogpt_platform/backend/backend/blocks/github/issues.py @@ -1,3 +1,4 @@ +import logging from urllib.parse import urlparse from typing_extensions import TypedDict @@ -5,7 +6,7 @@ from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField -from ._api import get_api +from ._api import convert_comment_url_to_api_endpoint, get_api from ._auth import ( TEST_CREDENTIALS, TEST_CREDENTIALS_INPUT, @@ -14,6 +15,8 @@ GithubCredentialsInput, ) +logger = logging.getLogger(__name__) + def is_github_url(url: str) -> bool: return urlparse(url).netloc == "github.com" @@ -77,7 +80,7 @@ def __init__(self): ) @staticmethod - def post_comment( + async def post_comment( credentials: GithubCredentials, issue_url: str, body_text: str ) -> tuple[int, str]: api = get_api(credentials) @@ -85,18 +88,18 @@ def post_comment( if "pull" in issue_url: issue_url = issue_url.replace("pull", "issues") comments_url = issue_url + "/comments" - response = api.post(comments_url, json=data) + response = await api.post(comments_url, json=data) comment = response.json() return comment["id"], comment["html_url"] - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - id, url = self.post_comment( + id, url = await self.post_comment( credentials, input_data.issue_url, input_data.comment, @@ -108,6 +111,229 @@ def run( # --8<-- [end:GithubCommentBlockExample] +class GithubUpdateCommentBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + comment_url: str = SchemaField( + description="URL of the GitHub comment", + placeholder="https://github.com/owner/repo/issues/1#issuecomment-123456789", + default="", + advanced=False, + ) + issue_url: str = SchemaField( + description="URL of the GitHub issue or pull request", + placeholder="https://github.com/owner/repo/issues/1", + default="", + ) + comment_id: str = SchemaField( + description="ID of the GitHub comment", + placeholder="123456789", + default="", + ) + comment: str = SchemaField( + description="Comment to update", + placeholder="Enter your comment", + ) + + class Output(BlockSchema): + id: int = SchemaField(description="ID of the updated comment") + url: str = SchemaField(description="URL to the comment on GitHub") + error: str = SchemaField( + description="Error message if the comment update failed" + ) + + def __init__(self): + super().__init__( + id="b3f4d747-10e3-4e69-8c51-f2be1d99c9a7", + description="This block updates a comment on a specified GitHub issue or pull request.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubUpdateCommentBlock.Input, + output_schema=GithubUpdateCommentBlock.Output, + test_input={ + "comment_url": "https://github.com/owner/repo/issues/1#issuecomment-123456789", + "comment": "This is an updated comment.", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", 123456789), + ( + "url", + "https://github.com/owner/repo/issues/1#issuecomment-123456789", + ), + ], + test_mock={ + "update_comment": lambda *args, **kwargs: ( + 123456789, + "https://github.com/owner/repo/issues/1#issuecomment-123456789", + ) + }, + ) + + @staticmethod + async def update_comment( + credentials: GithubCredentials, comment_url: str, body_text: str + ) -> tuple[int, str]: + api = get_api(credentials, convert_urls=False) + data = {"body": body_text} + url = convert_comment_url_to_api_endpoint(comment_url) + + logger.info(url) + response = await api.patch(url, json=data) + comment = response.json() + return comment["id"], comment["html_url"] + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + if ( + not input_data.comment_url + and input_data.comment_id + and input_data.issue_url + ): + parsed_url = urlparse(input_data.issue_url) + path_parts = parsed_url.path.strip("/").split("/") + owner, repo = path_parts[0], path_parts[1] + + input_data.comment_url = f"https://api.github.com/repos/{owner}/{repo}/issues/comments/{input_data.comment_id}" + + elif ( + not input_data.comment_url + and not input_data.comment_id + and input_data.issue_url + ): + raise ValueError( + "Must provide either comment_url or comment_id and issue_url" + ) + id, url = await self.update_comment( + credentials, + input_data.comment_url, + input_data.comment, + ) + yield "id", id + yield "url", url + + +class GithubListCommentsBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + issue_url: str = SchemaField( + description="URL of the GitHub issue or pull request", + placeholder="https://github.com/owner/repo/issues/1", + ) + + class Output(BlockSchema): + class CommentItem(TypedDict): + id: int + body: str + user: str + url: str + + comment: CommentItem = SchemaField( + title="Comment", description="Comments with their ID, body, user, and URL" + ) + comments: list[CommentItem] = SchemaField( + description="List of comments with their ID, body, user, and URL" + ) + error: str = SchemaField(description="Error message if listing comments failed") + + def __init__(self): + super().__init__( + id="c4b5fb63-0005-4a11-b35a-0c2467bd6b59", + description="This block lists all comments for a specified GitHub issue or pull request.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubListCommentsBlock.Input, + output_schema=GithubListCommentsBlock.Output, + test_input={ + "issue_url": "https://github.com/owner/repo/issues/1", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "comment", + { + "id": 123456789, + "body": "This is a test comment.", + "user": "test_user", + "url": "https://github.com/owner/repo/issues/1#issuecomment-123456789", + }, + ), + ( + "comments", + [ + { + "id": 123456789, + "body": "This is a test comment.", + "user": "test_user", + "url": "https://github.com/owner/repo/issues/1#issuecomment-123456789", + } + ], + ), + ], + test_mock={ + "list_comments": lambda *args, **kwargs: [ + { + "id": 123456789, + "body": "This is a test comment.", + "user": "test_user", + "url": "https://github.com/owner/repo/issues/1#issuecomment-123456789", + } + ] + }, + ) + + @staticmethod + async def list_comments( + credentials: GithubCredentials, issue_url: str + ) -> list[Output.CommentItem]: + parsed_url = urlparse(issue_url) + path_parts = parsed_url.path.strip("/").split("/") + + owner = path_parts[0] + repo = path_parts[1] + + # GitHub API uses 'issues' for both issues and pull requests when it comes to comments + issue_number = path_parts[3] # Whether 'issues/123' or 'pull/123' + + # Construct the proper API URL directly + api_url = f"https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}/comments" + + # Set convert_urls=False since we're already providing an API URL + api = get_api(credentials, convert_urls=False) + response = await api.get(api_url) + comments = response.json() + parsed_comments: list[GithubListCommentsBlock.Output.CommentItem] = [ + { + "id": comment["id"], + "body": comment["body"], + "user": comment["user"]["login"], + "url": comment["html_url"], + } + for comment in comments + ] + return parsed_comments + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + comments = await self.list_comments( + credentials, + input_data.issue_url, + ) + for comment in comments: + yield "comment", comment + yield "comments", comments + + class GithubMakeIssueBlock(Block): class Input(BlockSchema): credentials: GithubCredentialsInput = GithubCredentialsField("repo") @@ -156,24 +382,24 @@ def __init__(self): ) @staticmethod - def create_issue( + async def create_issue( credentials: GithubCredentials, repo_url: str, title: str, body: str ) -> tuple[int, str]: api = get_api(credentials) data = {"title": title, "body": body} issues_url = repo_url + "/issues" - response = api.post(issues_url, json=data) + response = await api.post(issues_url, json=data) issue = response.json() return issue["number"], issue["html_url"] - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - number, url = self.create_issue( + number, url = await self.create_issue( credentials, input_data.repo_url, input_data.title, @@ -226,25 +452,25 @@ def __init__(self): ) @staticmethod - def read_issue( + async def read_issue( credentials: GithubCredentials, issue_url: str ) -> tuple[str, str, str]: api = get_api(credentials) - response = api.get(issue_url) + response = await api.get(issue_url) data = response.json() title = data.get("title", "No title found") body = data.get("body", "No body content found") user = data.get("user", {}).get("login", "No user found") return title, body, user - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - title, body, user = self.read_issue( + title, body, user = await self.read_issue( credentials, input_data.issue_url, ) @@ -272,6 +498,9 @@ class IssueItem(TypedDict): issue: IssueItem = SchemaField( title="Issue", description="Issues with their title and URL" ) + issues: list[IssueItem] = SchemaField( + description="List of issues with their title and URL" + ) error: str = SchemaField(description="Error message if listing issues failed") def __init__(self): @@ -287,13 +516,22 @@ def __init__(self): }, test_credentials=TEST_CREDENTIALS, test_output=[ + ( + "issues", + [ + { + "title": "Issue 1", + "url": "https://github.com/owner/repo/issues/1", + } + ], + ), ( "issue", { "title": "Issue 1", "url": "https://github.com/owner/repo/issues/1", }, - ) + ), ], test_mock={ "list_issues": lambda *args, **kwargs: [ @@ -306,30 +544,32 @@ def __init__(self): ) @staticmethod - def list_issues( + async def list_issues( credentials: GithubCredentials, repo_url: str ) -> list[Output.IssueItem]: api = get_api(credentials) issues_url = repo_url + "/issues" - response = api.get(issues_url) + response = await api.get(issues_url) data = response.json() issues: list[GithubListIssuesBlock.Output.IssueItem] = [ {"title": issue["title"], "url": issue["html_url"]} for issue in data ] return issues - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - issues = self.list_issues( + issues = await self.list_issues( credentials, input_data.repo_url, ) - yield from (("issue", issue) for issue in issues) + yield "issues", issues + for issue in issues: + yield "issue", issue class GithubAddLabelBlock(Block): @@ -368,21 +608,23 @@ def __init__(self): ) @staticmethod - def add_label(credentials: GithubCredentials, issue_url: str, label: str) -> str: + async def add_label( + credentials: GithubCredentials, issue_url: str, label: str + ) -> str: api = get_api(credentials) data = {"labels": [label]} labels_url = issue_url + "/labels" - api.post(labels_url, json=data) + await api.post(labels_url, json=data) return "Label added successfully" - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - status = self.add_label( + status = await self.add_label( credentials, input_data.issue_url, input_data.label, @@ -428,20 +670,22 @@ def __init__(self): ) @staticmethod - def remove_label(credentials: GithubCredentials, issue_url: str, label: str) -> str: + async def remove_label( + credentials: GithubCredentials, issue_url: str, label: str + ) -> str: api = get_api(credentials) label_url = issue_url + f"/labels/{label}" - api.delete(label_url) + await api.delete(label_url) return "Label removed successfully" - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - status = self.remove_label( + status = await self.remove_label( credentials, input_data.issue_url, input_data.label, @@ -489,7 +733,7 @@ def __init__(self): ) @staticmethod - def assign_issue( + async def assign_issue( credentials: GithubCredentials, issue_url: str, assignee: str, @@ -497,17 +741,17 @@ def assign_issue( api = get_api(credentials) assignees_url = issue_url + "/assignees" data = {"assignees": [assignee]} - api.post(assignees_url, json=data) + await api.post(assignees_url, json=data) return "Issue assigned successfully" - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - status = self.assign_issue( + status = await self.assign_issue( credentials, input_data.issue_url, input_data.assignee, @@ -555,7 +799,7 @@ def __init__(self): ) @staticmethod - def unassign_issue( + async def unassign_issue( credentials: GithubCredentials, issue_url: str, assignee: str, @@ -563,17 +807,17 @@ def unassign_issue( api = get_api(credentials) assignees_url = issue_url + "/assignees" data = {"assignees": [assignee]} - api.delete(assignees_url, json=data) + await api.delete(assignees_url, json=data) return "Issue unassigned successfully" - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - status = self.unassign_issue( + status = await self.unassign_issue( credentials, input_data.issue_url, input_data.assignee, diff --git a/autogpt_platform/backend/backend/blocks/github/pull_requests.py b/autogpt_platform/backend/backend/blocks/github/pull_requests.py index e8fad2daa66a..90370f8166e7 100644 --- a/autogpt_platform/backend/backend/blocks/github/pull_requests.py +++ b/autogpt_platform/backend/backend/blocks/github/pull_requests.py @@ -31,7 +31,12 @@ class PRItem(TypedDict): pull_request: PRItem = SchemaField( title="Pull Request", description="PRs with their title and URL" ) - error: str = SchemaField(description="Error message if listing issues failed") + pull_requests: list[PRItem] = SchemaField( + description="List of pull requests with their title and URL" + ) + error: str = SchemaField( + description="Error message if listing pull requests failed" + ) def __init__(self): super().__init__( @@ -46,13 +51,22 @@ def __init__(self): }, test_credentials=TEST_CREDENTIALS, test_output=[ + ( + "pull_requests", + [ + { + "title": "Pull request 1", + "url": "https://github.com/owner/repo/pull/1", + } + ], + ), ( "pull_request", { "title": "Pull request 1", "url": "https://github.com/owner/repo/pull/1", }, - ) + ), ], test_mock={ "list_prs": lambda *args, **kwargs: [ @@ -65,28 +79,32 @@ def __init__(self): ) @staticmethod - def list_prs(credentials: GithubCredentials, repo_url: str) -> list[Output.PRItem]: + async def list_prs( + credentials: GithubCredentials, repo_url: str + ) -> list[Output.PRItem]: api = get_api(credentials) pulls_url = repo_url + "/pulls" - response = api.get(pulls_url) + response = await api.get(pulls_url) data = response.json() pull_requests: list[GithubListPullRequestsBlock.Output.PRItem] = [ {"title": pr["title"], "url": pr["html_url"]} for pr in data ] return pull_requests - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - pull_requests = self.list_prs( + pull_requests = await self.list_prs( credentials, input_data.repo_url, ) - yield from (("pull_request", pr) for pr in pull_requests) + yield "pull_requests", pull_requests + for pr in pull_requests: + yield "pull_request", pr class GithubMakePullRequestBlock(Block): @@ -153,7 +171,7 @@ def __init__(self): ) @staticmethod - def create_pr( + async def create_pr( credentials: GithubCredentials, repo_url: str, title: str, @@ -164,11 +182,11 @@ def create_pr( api = get_api(credentials) pulls_url = repo_url + "/pulls" data = {"title": title, "body": body, "head": head, "base": base} - response = api.post(pulls_url, json=data) + response = await api.post(pulls_url, json=data) pr_data = response.json() return pr_data["number"], pr_data["html_url"] - def run( + async def run( self, input_data: Input, *, @@ -176,7 +194,7 @@ def run( **kwargs, ) -> BlockOutput: try: - number, url = self.create_pr( + number, url = await self.create_pr( credentials, input_data.repo_url, input_data.title, @@ -200,6 +218,7 @@ class Input(BlockSchema): include_pr_changes: bool = SchemaField( description="Whether to include the changes made in the pull request", default=False, + advanced=False, ) class Output(BlockSchema): @@ -241,39 +260,55 @@ def __init__(self): ) @staticmethod - def read_pr(credentials: GithubCredentials, pr_url: str) -> tuple[str, str, str]: + async def read_pr( + credentials: GithubCredentials, pr_url: str + ) -> tuple[str, str, str]: api = get_api(credentials) - # Adjust the URL to access the issue endpoint for PR metadata issue_url = pr_url.replace("/pull/", "/issues/") - response = api.get(issue_url) + response = await api.get(issue_url) data = response.json() title = data.get("title", "No title found") body = data.get("body", "No body content found") - author = data.get("user", {}).get("login", "No user found") + author = data.get("user", {}).get("login", "Unknown author") return title, body, author @staticmethod - def read_pr_changes(credentials: GithubCredentials, pr_url: str) -> str: + async def read_pr_changes(credentials: GithubCredentials, pr_url: str) -> str: api = get_api(credentials) files_url = prepare_pr_api_url(pr_url=pr_url, path="files") - response = api.get(files_url) + response = await api.get(files_url) files = response.json() changes = [] for file in files: - filename = file.get("filename") - patch = file.get("patch") - if filename and patch: - changes.append(f"File: {filename}\n{patch}") + status: str = file.get("status", "") + diff: str = file.get("patch", "") + if status != "removed": + is_filename: str = file.get("filename", "") + was_filename: str = ( + file.get("previous_filename", is_filename) + if status != "added" + else "" + ) + else: + is_filename = "" + was_filename: str = file.get("filename", "") + + patch_header = "" + if was_filename: + patch_header += f"--- {was_filename}\n" + if is_filename: + patch_header += f"+++ {is_filename}\n" + changes.append(patch_header + diff) return "\n\n".join(changes) - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - title, body, author = self.read_pr( + title, body, author = await self.read_pr( credentials, input_data.pr_url, ) @@ -282,7 +317,7 @@ def run( yield "author", author if input_data.include_pr_changes: - changes = self.read_pr_changes( + changes = await self.read_pr_changes( credentials, input_data.pr_url, ) @@ -329,16 +364,16 @@ def __init__(self): ) @staticmethod - def assign_reviewer( + async def assign_reviewer( credentials: GithubCredentials, pr_url: str, reviewer: str ) -> str: api = get_api(credentials) reviewers_url = prepare_pr_api_url(pr_url=pr_url, path="requested_reviewers") data = {"reviewers": [reviewer]} - api.post(reviewers_url, json=data) + await api.post(reviewers_url, json=data) return "Reviewer assigned successfully" - def run( + async def run( self, input_data: Input, *, @@ -346,7 +381,7 @@ def run( **kwargs, ) -> BlockOutput: try: - status = self.assign_reviewer( + status = await self.assign_reviewer( credentials, input_data.pr_url, input_data.reviewer, @@ -396,16 +431,16 @@ def __init__(self): ) @staticmethod - def unassign_reviewer( + async def unassign_reviewer( credentials: GithubCredentials, pr_url: str, reviewer: str ) -> str: api = get_api(credentials) reviewers_url = prepare_pr_api_url(pr_url=pr_url, path="requested_reviewers") data = {"reviewers": [reviewer]} - api.delete(reviewers_url, json=data) + await api.delete(reviewers_url, json=data) return "Reviewer unassigned successfully" - def run( + async def run( self, input_data: Input, *, @@ -413,7 +448,7 @@ def run( **kwargs, ) -> BlockOutput: try: - status = self.unassign_reviewer( + status = await self.unassign_reviewer( credentials, input_data.pr_url, input_data.reviewer, @@ -440,6 +475,9 @@ class ReviewerItem(TypedDict): title="Reviewer", description="Reviewers with their username and profile URL", ) + reviewers: list[ReviewerItem] = SchemaField( + description="List of reviewers with their username and profile URL" + ) error: str = SchemaField( description="Error message if listing reviewers failed" ) @@ -457,13 +495,22 @@ def __init__(self): }, test_credentials=TEST_CREDENTIALS, test_output=[ + ( + "reviewers", + [ + { + "username": "reviewer1", + "url": "https://github.com/reviewer1", + } + ], + ), ( "reviewer", { "username": "reviewer1", "url": "https://github.com/reviewer1", }, - ) + ), ], test_mock={ "list_reviewers": lambda *args, **kwargs: [ @@ -476,12 +523,12 @@ def __init__(self): ) @staticmethod - def list_reviewers( + async def list_reviewers( credentials: GithubCredentials, pr_url: str ) -> list[Output.ReviewerItem]: api = get_api(credentials) reviewers_url = prepare_pr_api_url(pr_url=pr_url, path="requested_reviewers") - response = api.get(reviewers_url) + response = await api.get(reviewers_url) data = response.json() reviewers: list[GithubListPRReviewersBlock.Output.ReviewerItem] = [ {"username": reviewer["login"], "url": reviewer["html_url"]} @@ -489,18 +536,20 @@ def list_reviewers( ] return reviewers - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - reviewers = self.list_reviewers( + reviewers = await self.list_reviewers( credentials, input_data.pr_url, ) - yield from (("reviewer", reviewer) for reviewer in reviewers) + yield "reviewers", reviewers + for reviewer in reviewers: + yield "reviewer", reviewer def prepare_pr_api_url(pr_url: str, path: str) -> str: diff --git a/autogpt_platform/backend/backend/blocks/github/repo.py b/autogpt_platform/backend/backend/blocks/github/repo.py index 7e2521181862..08c1d038d30a 100644 --- a/autogpt_platform/backend/backend/blocks/github/repo.py +++ b/autogpt_platform/backend/backend/blocks/github/repo.py @@ -31,6 +31,9 @@ class TagItem(TypedDict): tag: TagItem = SchemaField( title="Tag", description="Tags with their name and file tree browser URL" ) + tags: list[TagItem] = SchemaField( + description="List of tags with their name and file tree browser URL" + ) error: str = SchemaField(description="Error message if listing tags failed") def __init__(self): @@ -46,13 +49,22 @@ def __init__(self): }, test_credentials=TEST_CREDENTIALS, test_output=[ + ( + "tags", + [ + { + "name": "v1.0.0", + "url": "https://github.com/owner/repo/tree/v1.0.0", + } + ], + ), ( "tag", { "name": "v1.0.0", "url": "https://github.com/owner/repo/tree/v1.0.0", }, - ) + ), ], test_mock={ "list_tags": lambda *args, **kwargs: [ @@ -65,12 +77,12 @@ def __init__(self): ) @staticmethod - def list_tags( + async def list_tags( credentials: GithubCredentials, repo_url: str ) -> list[Output.TagItem]: api = get_api(credentials) tags_url = repo_url + "/tags" - response = api.get(tags_url) + response = await api.get(tags_url) data = response.json() repo_path = repo_url.replace("https://github.com/", "") tags: list[GithubListTagsBlock.Output.TagItem] = [ @@ -82,18 +94,20 @@ def list_tags( ] return tags - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - tags = self.list_tags( + tags = await self.list_tags( credentials, input_data.repo_url, ) - yield from (("tag", tag) for tag in tags) + yield "tags", tags + for tag in tags: + yield "tag", tag class GithubListBranchesBlock(Block): @@ -113,6 +127,9 @@ class BranchItem(TypedDict): title="Branch", description="Branches with their name and file tree browser URL", ) + branches: list[BranchItem] = SchemaField( + description="List of branches with their name and file tree browser URL" + ) error: str = SchemaField(description="Error message if listing branches failed") def __init__(self): @@ -128,13 +145,22 @@ def __init__(self): }, test_credentials=TEST_CREDENTIALS, test_output=[ + ( + "branches", + [ + { + "name": "main", + "url": "https://github.com/owner/repo/tree/main", + } + ], + ), ( "branch", { "name": "main", "url": "https://github.com/owner/repo/tree/main", }, - ) + ), ], test_mock={ "list_branches": lambda *args, **kwargs: [ @@ -147,12 +173,12 @@ def __init__(self): ) @staticmethod - def list_branches( + async def list_branches( credentials: GithubCredentials, repo_url: str ) -> list[Output.BranchItem]: api = get_api(credentials) branches_url = repo_url + "/branches" - response = api.get(branches_url) + response = await api.get(branches_url) data = response.json() repo_path = repo_url.replace("https://github.com/", "") branches: list[GithubListBranchesBlock.Output.BranchItem] = [ @@ -164,18 +190,20 @@ def list_branches( ] return branches - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - branches = self.list_branches( + branches = await self.list_branches( credentials, input_data.repo_url, ) - yield from (("branch", branch) for branch in branches) + yield "branches", branches + for branch in branches: + yield "branch", branch class GithubListDiscussionsBlock(Block): @@ -197,6 +225,9 @@ class DiscussionItem(TypedDict): discussion: DiscussionItem = SchemaField( title="Discussion", description="Discussions with their title and URL" ) + discussions: list[DiscussionItem] = SchemaField( + description="List of discussions with their title and URL" + ) error: str = SchemaField( description="Error message if listing discussions failed" ) @@ -215,13 +246,22 @@ def __init__(self): }, test_credentials=TEST_CREDENTIALS, test_output=[ + ( + "discussions", + [ + { + "title": "Discussion 1", + "url": "https://github.com/owner/repo/discussions/1", + } + ], + ), ( "discussion", { "title": "Discussion 1", "url": "https://github.com/owner/repo/discussions/1", }, - ) + ), ], test_mock={ "list_discussions": lambda *args, **kwargs: [ @@ -234,7 +274,7 @@ def __init__(self): ) @staticmethod - def list_discussions( + async def list_discussions( credentials: GithubCredentials, repo_url: str, num_discussions: int ) -> list[Output.DiscussionItem]: api = get_api(credentials) @@ -254,7 +294,7 @@ def list_discussions( } """ variables = {"owner": owner, "repo": repo, "num": num_discussions} - response = api.post( + response = await api.post( "https://api.github.com/graphql", json={"query": query, "variables": variables}, ) @@ -265,17 +305,21 @@ def list_discussions( ] return discussions - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - discussions = self.list_discussions( - credentials, input_data.repo_url, input_data.num_discussions + discussions = await self.list_discussions( + credentials, + input_data.repo_url, + input_data.num_discussions, ) - yield from (("discussion", discussion) for discussion in discussions) + yield "discussions", discussions + for discussion in discussions: + yield "discussion", discussion class GithubListReleasesBlock(Block): @@ -295,6 +339,9 @@ class ReleaseItem(TypedDict): title="Release", description="Releases with their name and file tree browser URL", ) + releases: list[ReleaseItem] = SchemaField( + description="List of releases with their name and file tree browser URL" + ) error: str = SchemaField(description="Error message if listing releases failed") def __init__(self): @@ -310,13 +357,22 @@ def __init__(self): }, test_credentials=TEST_CREDENTIALS, test_output=[ + ( + "releases", + [ + { + "name": "v1.0.0", + "url": "https://github.com/owner/repo/releases/tag/v1.0.0", + } + ], + ), ( "release", { "name": "v1.0.0", "url": "https://github.com/owner/repo/releases/tag/v1.0.0", }, - ) + ), ], test_mock={ "list_releases": lambda *args, **kwargs: [ @@ -329,30 +385,32 @@ def __init__(self): ) @staticmethod - def list_releases( + async def list_releases( credentials: GithubCredentials, repo_url: str ) -> list[Output.ReleaseItem]: api = get_api(credentials) releases_url = repo_url + "/releases" - response = api.get(releases_url) + response = await api.get(releases_url) data = response.json() releases: list[GithubListReleasesBlock.Output.ReleaseItem] = [ {"name": release["name"], "url": release["html_url"]} for release in data ] return releases - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - releases = self.list_releases( + releases = await self.list_releases( credentials, input_data.repo_url, ) - yield from (("release", release) for release in releases) + yield "releases", releases + for release in releases: + yield "release", release class GithubReadFileBlock(Block): @@ -405,40 +463,40 @@ def __init__(self): ) @staticmethod - def read_file( + async def read_file( credentials: GithubCredentials, repo_url: str, file_path: str, branch: str ) -> tuple[str, int]: api = get_api(credentials) content_url = repo_url + f"/contents/{file_path}?ref={branch}" - response = api.get(content_url) - content = response.json() + response = await api.get(content_url) + data = response.json() - if isinstance(content, list): + if isinstance(data, list): # Multiple entries of different types exist at this path - if not (file := next((f for f in content if f["type"] == "file"), None)): + if not (file := next((f for f in data if f["type"] == "file"), None)): raise TypeError("Not a file") - content = file + data = file - if content["type"] != "file": + if data["type"] != "file": raise TypeError("Not a file") - return content["content"], content["size"] + return data["content"], data["size"] - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - raw_content, size = self.read_file( + content, size = await self.read_file( credentials, input_data.repo_url, - input_data.file_path.lstrip("/"), + input_data.file_path, input_data.branch, ) - yield "raw_content", raw_content - yield "text_content", base64.b64decode(raw_content).decode("utf-8") + yield "raw_content", content + yield "text_content", base64.b64decode(content).decode("utf-8") yield "size", size @@ -515,52 +573,55 @@ def __init__(self): ) @staticmethod - def read_folder( + async def read_folder( credentials: GithubCredentials, repo_url: str, folder_path: str, branch: str ) -> tuple[list[Output.FileEntry], list[Output.DirEntry]]: api = get_api(credentials) contents_url = repo_url + f"/contents/{folder_path}?ref={branch}" - response = api.get(contents_url) - content = response.json() + response = await api.get(contents_url) + data = response.json() - if not isinstance(content, list): + if not isinstance(data, list): raise TypeError("Not a folder") - files = [ + files: list[GithubReadFolderBlock.Output.FileEntry] = [ GithubReadFolderBlock.Output.FileEntry( name=entry["name"], path=entry["path"], size=entry["size"], ) - for entry in content + for entry in data if entry["type"] == "file" ] - dirs = [ + + dirs: list[GithubReadFolderBlock.Output.DirEntry] = [ GithubReadFolderBlock.Output.DirEntry( name=entry["name"], path=entry["path"], ) - for entry in content + for entry in data if entry["type"] == "dir" ] return files, dirs - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - files, dirs = self.read_folder( + files, dirs = await self.read_folder( credentials, input_data.repo_url, input_data.folder_path.lstrip("/"), input_data.branch, ) - yield from (("file", file) for file in files) - yield from (("dir", dir) for dir in dirs) + for file in files: + yield "file", file + for dir in dirs: + yield "dir", dir class GithubMakeBranchBlock(Block): @@ -606,32 +667,35 @@ def __init__(self): ) @staticmethod - def create_branch( + async def create_branch( credentials: GithubCredentials, repo_url: str, new_branch: str, source_branch: str, ) -> str: api = get_api(credentials) - # Get the SHA of the source branch ref_url = repo_url + f"/git/refs/heads/{source_branch}" - response = api.get(ref_url) - sha = response.json()["object"]["sha"] + response = await api.get(ref_url) + data = response.json() + sha = data["object"]["sha"] # Create the new branch - create_ref_url = repo_url + "/git/refs" - data = {"ref": f"refs/heads/{new_branch}", "sha": sha} - response = api.post(create_ref_url, json=data) + new_ref_url = repo_url + "/git/refs" + data = { + "ref": f"refs/heads/{new_branch}", + "sha": sha, + } + response = await api.post(new_ref_url, json=data) return "Branch created successfully" - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - status = self.create_branch( + status = await self.create_branch( credentials, input_data.repo_url, input_data.new_branch, @@ -678,24 +742,432 @@ def __init__(self): ) @staticmethod - def delete_branch( + async def delete_branch( credentials: GithubCredentials, repo_url: str, branch: str ) -> str: api = get_api(credentials) ref_url = repo_url + f"/git/refs/heads/{branch}" - api.delete(ref_url) + await api.delete(ref_url) return "Branch deleted successfully" - def run( + async def run( self, input_data: Input, *, credentials: GithubCredentials, **kwargs, ) -> BlockOutput: - status = self.delete_branch( + status = await self.delete_branch( credentials, input_data.repo_url, input_data.branch, ) yield "status", status + + +class GithubCreateFileBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + file_path: str = SchemaField( + description="Path where the file should be created", + placeholder="path/to/file.txt", + ) + content: str = SchemaField( + description="Content to write to the file", + placeholder="File content here", + ) + branch: str = SchemaField( + description="Branch where the file should be created", + default="main", + ) + commit_message: str = SchemaField( + description="Message for the commit", + default="Create new file", + ) + + class Output(BlockSchema): + url: str = SchemaField(description="URL of the created file") + sha: str = SchemaField(description="SHA of the commit") + error: str = SchemaField( + description="Error message if the file creation failed" + ) + + def __init__(self): + super().__init__( + id="8fd132ac-b917-428a-8159-d62893e8a3fe", + description="This block creates a new file in a GitHub repository.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubCreateFileBlock.Input, + output_schema=GithubCreateFileBlock.Output, + test_input={ + "repo_url": "https://github.com/owner/repo", + "file_path": "test/file.txt", + "content": "Test content", + "branch": "main", + "commit_message": "Create test file", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("url", "https://github.com/owner/repo/blob/main/test/file.txt"), + ("sha", "abc123"), + ], + test_mock={ + "create_file": lambda *args, **kwargs: ( + "https://github.com/owner/repo/blob/main/test/file.txt", + "abc123", + ) + }, + ) + + @staticmethod + async def create_file( + credentials: GithubCredentials, + repo_url: str, + file_path: str, + content: str, + branch: str, + commit_message: str, + ) -> tuple[str, str]: + api = get_api(credentials) + contents_url = repo_url + f"/contents/{file_path}" + content_base64 = base64.b64encode(content.encode()).decode() + data = { + "message": commit_message, + "content": content_base64, + "branch": branch, + } + response = await api.put(contents_url, json=data) + data = response.json() + return data["content"]["html_url"], data["commit"]["sha"] + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + url, sha = await self.create_file( + credentials, + input_data.repo_url, + input_data.file_path, + input_data.content, + input_data.branch, + input_data.commit_message, + ) + yield "url", url + yield "sha", sha + except Exception as e: + yield "error", str(e) + + +class GithubUpdateFileBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + file_path: str = SchemaField( + description="Path to the file to update", + placeholder="path/to/file.txt", + ) + content: str = SchemaField( + description="New content for the file", + placeholder="Updated content here", + ) + branch: str = SchemaField( + description="Branch containing the file", + default="main", + ) + commit_message: str = SchemaField( + description="Message for the commit", + default="Update file", + ) + + class Output(BlockSchema): + url: str = SchemaField(description="URL of the updated file") + sha: str = SchemaField(description="SHA of the commit") + error: str = SchemaField(description="Error message if the file update failed") + + def __init__(self): + super().__init__( + id="30be12a4-57cb-4aa4-baf5-fcc68d136076", + description="This block updates an existing file in a GitHub repository.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubUpdateFileBlock.Input, + output_schema=GithubUpdateFileBlock.Output, + test_input={ + "repo_url": "https://github.com/owner/repo", + "file_path": "test/file.txt", + "content": "Updated content", + "branch": "main", + "commit_message": "Update test file", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("url", "https://github.com/owner/repo/blob/main/test/file.txt"), + ("sha", "def456"), + ], + test_mock={ + "update_file": lambda *args, **kwargs: ( + "https://github.com/owner/repo/blob/main/test/file.txt", + "def456", + ) + }, + ) + + @staticmethod + async def update_file( + credentials: GithubCredentials, + repo_url: str, + file_path: str, + content: str, + branch: str, + commit_message: str, + ) -> tuple[str, str]: + api = get_api(credentials) + contents_url = repo_url + f"/contents/{file_path}" + params = {"ref": branch} + response = await api.get(contents_url, params=params) + data = response.json() + + # Convert new content to base64 + content_base64 = base64.b64encode(content.encode()).decode() + data = { + "message": commit_message, + "content": content_base64, + "sha": data["sha"], + "branch": branch, + } + response = await api.put(contents_url, json=data) + data = response.json() + return data["content"]["html_url"], data["commit"]["sha"] + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + url, sha = await self.update_file( + credentials, + input_data.repo_url, + input_data.file_path, + input_data.content, + input_data.branch, + input_data.commit_message, + ) + yield "url", url + yield "sha", sha + except Exception as e: + yield "error", str(e) + + +class GithubCreateRepositoryBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + name: str = SchemaField( + description="Name of the repository to create", + placeholder="my-new-repo", + ) + description: str = SchemaField( + description="Description of the repository", + placeholder="A description of the repository", + default="", + ) + private: bool = SchemaField( + description="Whether the repository should be private", + default=False, + ) + auto_init: bool = SchemaField( + description="Whether to initialize the repository with a README", + default=True, + ) + gitignore_template: str = SchemaField( + description="Git ignore template to use (e.g., Python, Node, Java)", + default="", + ) + + class Output(BlockSchema): + url: str = SchemaField(description="URL of the created repository") + clone_url: str = SchemaField(description="Git clone URL of the repository") + error: str = SchemaField( + description="Error message if the repository creation failed" + ) + + def __init__(self): + super().__init__( + id="029ec3b8-1cfd-46d3-b6aa-28e4a706efd1", + description="This block creates a new GitHub repository.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubCreateRepositoryBlock.Input, + output_schema=GithubCreateRepositoryBlock.Output, + test_input={ + "name": "test-repo", + "description": "A test repository", + "private": False, + "auto_init": True, + "gitignore_template": "Python", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("url", "https://github.com/owner/test-repo"), + ("clone_url", "https://github.com/owner/test-repo.git"), + ], + test_mock={ + "create_repository": lambda *args, **kwargs: ( + "https://github.com/owner/test-repo", + "https://github.com/owner/test-repo.git", + ) + }, + ) + + @staticmethod + async def create_repository( + credentials: GithubCredentials, + name: str, + description: str, + private: bool, + auto_init: bool, + gitignore_template: str, + ) -> tuple[str, str]: + api = get_api(credentials) + data = { + "name": name, + "description": description, + "private": private, + "auto_init": auto_init, + "gitignore_template": gitignore_template, + } + response = await api.post("https://api.github.com/user/repos", json=data) + data = response.json() + return data["html_url"], data["clone_url"] + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + url, clone_url = await self.create_repository( + credentials, + input_data.name, + input_data.description, + input_data.private, + input_data.auto_init, + input_data.gitignore_template, + ) + yield "url", url + yield "clone_url", clone_url + except Exception as e: + yield "error", str(e) + + +class GithubListStargazersBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + + class Output(BlockSchema): + class StargazerItem(TypedDict): + username: str + url: str + + stargazer: StargazerItem = SchemaField( + title="Stargazer", + description="Stargazers with their username and profile URL", + ) + stargazers: list[StargazerItem] = SchemaField( + description="List of stargazers with their username and profile URL" + ) + error: str = SchemaField( + description="Error message if listing stargazers failed" + ) + + def __init__(self): + super().__init__( + id="a4b9c2d1-e5f6-4g7h-8i9j-0k1l2m3n4o5p", # Generated unique UUID + description="This block lists all users who have starred a specified GitHub repository.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubListStargazersBlock.Input, + output_schema=GithubListStargazersBlock.Output, + test_input={ + "repo_url": "https://github.com/owner/repo", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "stargazers", + [ + { + "username": "octocat", + "url": "https://github.com/octocat", + } + ], + ), + ( + "stargazer", + { + "username": "octocat", + "url": "https://github.com/octocat", + }, + ), + ], + test_mock={ + "list_stargazers": lambda *args, **kwargs: [ + { + "username": "octocat", + "url": "https://github.com/octocat", + } + ] + }, + ) + + @staticmethod + async def list_stargazers( + credentials: GithubCredentials, repo_url: str + ) -> list[Output.StargazerItem]: + api = get_api(credentials) + stargazers_url = repo_url + "/stargazers" + response = await api.get(stargazers_url) + data = response.json() + stargazers: list[GithubListStargazersBlock.Output.StargazerItem] = [ + { + "username": stargazer["login"], + "url": stargazer["html_url"], + } + for stargazer in data + ] + return stargazers + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + stargazers = await self.list_stargazers( + credentials, + input_data.repo_url, + ) + yield "stargazers", stargazers + for stargazer in stargazers: + yield "stargazer", stargazer diff --git a/autogpt_platform/backend/backend/blocks/github/reviews.py b/autogpt_platform/backend/backend/blocks/github/reviews.py new file mode 100644 index 000000000000..2b909da8ffd1 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/github/reviews.py @@ -0,0 +1,840 @@ +import logging +from enum import Enum +from typing import Any, List, Optional + +from typing_extensions import TypedDict + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + +from ._api import get_api +from ._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + GithubCredentials, + GithubCredentialsField, + GithubCredentialsInput, +) + +logger = logging.getLogger(__name__) + + +class ReviewEvent(Enum): + COMMENT = "COMMENT" + APPROVE = "APPROVE" + REQUEST_CHANGES = "REQUEST_CHANGES" + + +class GithubCreatePRReviewBlock(Block): + class Input(BlockSchema): + class ReviewComment(TypedDict, total=False): + path: str + position: Optional[int] + body: str + line: Optional[int] # Will be used as position if position not provided + + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo: str = SchemaField( + description="GitHub repository", + placeholder="owner/repo", + ) + pr_number: int = SchemaField( + description="Pull request number", + placeholder="123", + ) + body: str = SchemaField( + description="Body of the review comment", + placeholder="Enter your review comment", + ) + event: ReviewEvent = SchemaField( + description="The review action to perform", + default=ReviewEvent.COMMENT, + ) + create_as_draft: bool = SchemaField( + description="Create the review as a draft (pending) or post it immediately", + default=False, + advanced=False, + ) + comments: Optional[List[ReviewComment]] = SchemaField( + description="Optional inline comments to add to specific files/lines. Note: Only path, body, and position are supported. Position is line number in diff from first @@ hunk.", + default=None, + advanced=True, + ) + + class Output(BlockSchema): + review_id: int = SchemaField(description="ID of the created review") + state: str = SchemaField( + description="State of the review (e.g., PENDING, COMMENTED, APPROVED, CHANGES_REQUESTED)" + ) + html_url: str = SchemaField(description="URL of the created review") + error: str = SchemaField( + description="Error message if the review creation failed" + ) + + def __init__(self): + super().__init__( + id="84754b30-97d2-4c37-a3b8-eb39f268275b", + description="This block creates a review on a GitHub pull request with optional inline comments. You can create it as a draft or post immediately. Note: For inline comments, 'position' should be the line number in the diff (starting from the first @@ hunk header).", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubCreatePRReviewBlock.Input, + output_schema=GithubCreatePRReviewBlock.Output, + test_input={ + "repo": "owner/repo", + "pr_number": 1, + "body": "This looks good to me!", + "event": "APPROVE", + "create_as_draft": False, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("review_id", 123456), + ("state", "APPROVED"), + ( + "html_url", + "https://github.com/owner/repo/pull/1#pullrequestreview-123456", + ), + ], + test_mock={ + "create_review": lambda *args, **kwargs: ( + 123456, + "APPROVED", + "https://github.com/owner/repo/pull/1#pullrequestreview-123456", + ) + }, + ) + + @staticmethod + async def create_review( + credentials: GithubCredentials, + repo: str, + pr_number: int, + body: str, + event: ReviewEvent, + create_as_draft: bool, + comments: Optional[List[Input.ReviewComment]] = None, + ) -> tuple[int, str, str]: + api = get_api(credentials, convert_urls=False) + + # GitHub API endpoint for creating reviews + reviews_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/reviews" + + # Get commit_id if we have comments + commit_id = None + if comments: + # Get PR details to get the head commit for inline comments + pr_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}" + pr_response = await api.get(pr_url) + pr_data = pr_response.json() + commit_id = pr_data["head"]["sha"] + + # Prepare the request data + # If create_as_draft is True, omit the event field (creates a PENDING review) + # Otherwise, use the actual event value which will auto-submit the review + data: dict[str, Any] = {"body": body} + + # Add commit_id if we have it + if commit_id: + data["commit_id"] = commit_id + + # Add comments if provided + if comments: + # Process comments to ensure they have the required fields + processed_comments = [] + for comment in comments: + comment_data: dict = { + "path": comment.get("path", ""), + "body": comment.get("body", ""), + } + # Add position or line + # Note: For review comments, only position is supported (not line/side) + if "position" in comment and comment.get("position") is not None: + comment_data["position"] = comment.get("position") + elif "line" in comment and comment.get("line") is not None: + # Note: Using line as position - may not work correctly + # Position should be calculated from the diff + comment_data["position"] = comment.get("line") + + # Note: side, start_line, and start_side are NOT supported for review comments + # They are only for standalone PR comments + + processed_comments.append(comment_data) + + data["comments"] = processed_comments + + if not create_as_draft: + # Only add event field if not creating a draft + data["event"] = event.value + + # Create the review + response = await api.post(reviews_url, json=data) + review_data = response.json() + + return review_data["id"], review_data["state"], review_data["html_url"] + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + review_id, state, html_url = await self.create_review( + credentials, + input_data.repo, + input_data.pr_number, + input_data.body, + input_data.event, + input_data.create_as_draft, + input_data.comments, + ) + yield "review_id", review_id + yield "state", state + yield "html_url", html_url + except Exception as e: + yield "error", str(e) + + +class GithubListPRReviewsBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo: str = SchemaField( + description="GitHub repository", + placeholder="owner/repo", + ) + pr_number: int = SchemaField( + description="Pull request number", + placeholder="123", + ) + + class Output(BlockSchema): + class ReviewItem(TypedDict): + id: int + user: str + state: str + body: str + html_url: str + + review: ReviewItem = SchemaField( + title="Review", + description="Individual review with details", + ) + reviews: list[ReviewItem] = SchemaField( + description="List of all reviews on the pull request" + ) + error: str = SchemaField(description="Error message if listing reviews failed") + + def __init__(self): + super().__init__( + id="f79bc6eb-33c0-4099-9c0f-d664ae1ba4d0", + description="This block lists all reviews for a specified GitHub pull request.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubListPRReviewsBlock.Input, + output_schema=GithubListPRReviewsBlock.Output, + test_input={ + "repo": "owner/repo", + "pr_number": 1, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "reviews", + [ + { + "id": 123456, + "user": "reviewer1", + "state": "APPROVED", + "body": "Looks good!", + "html_url": "https://github.com/owner/repo/pull/1#pullrequestreview-123456", + } + ], + ), + ( + "review", + { + "id": 123456, + "user": "reviewer1", + "state": "APPROVED", + "body": "Looks good!", + "html_url": "https://github.com/owner/repo/pull/1#pullrequestreview-123456", + }, + ), + ], + test_mock={ + "list_reviews": lambda *args, **kwargs: [ + { + "id": 123456, + "user": "reviewer1", + "state": "APPROVED", + "body": "Looks good!", + "html_url": "https://github.com/owner/repo/pull/1#pullrequestreview-123456", + } + ] + }, + ) + + @staticmethod + async def list_reviews( + credentials: GithubCredentials, repo: str, pr_number: int + ) -> list[Output.ReviewItem]: + api = get_api(credentials, convert_urls=False) + + # GitHub API endpoint for listing reviews + reviews_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/reviews" + + response = await api.get(reviews_url) + data = response.json() + + reviews: list[GithubListPRReviewsBlock.Output.ReviewItem] = [ + { + "id": review["id"], + "user": review["user"]["login"], + "state": review["state"], + "body": review.get("body", ""), + "html_url": review["html_url"], + } + for review in data + ] + return reviews + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + reviews = await self.list_reviews( + credentials, + input_data.repo, + input_data.pr_number, + ) + yield "reviews", reviews + for review in reviews: + yield "review", review + + +class GithubSubmitPendingReviewBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo: str = SchemaField( + description="GitHub repository", + placeholder="owner/repo", + ) + pr_number: int = SchemaField( + description="Pull request number", + placeholder="123", + ) + review_id: int = SchemaField( + description="ID of the pending review to submit", + placeholder="123456", + ) + event: ReviewEvent = SchemaField( + description="The review action to perform when submitting", + default=ReviewEvent.COMMENT, + ) + + class Output(BlockSchema): + state: str = SchemaField(description="State of the submitted review") + html_url: str = SchemaField(description="URL of the submitted review") + error: str = SchemaField( + description="Error message if the review submission failed" + ) + + def __init__(self): + super().__init__( + id="2e468217-7ca0-4201-9553-36e93eb9357a", + description="This block submits a pending (draft) review on a GitHub pull request.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubSubmitPendingReviewBlock.Input, + output_schema=GithubSubmitPendingReviewBlock.Output, + test_input={ + "repo": "owner/repo", + "pr_number": 1, + "review_id": 123456, + "event": "APPROVE", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("state", "APPROVED"), + ( + "html_url", + "https://github.com/owner/repo/pull/1#pullrequestreview-123456", + ), + ], + test_mock={ + "submit_review": lambda *args, **kwargs: ( + "APPROVED", + "https://github.com/owner/repo/pull/1#pullrequestreview-123456", + ) + }, + ) + + @staticmethod + async def submit_review( + credentials: GithubCredentials, + repo: str, + pr_number: int, + review_id: int, + event: ReviewEvent, + ) -> tuple[str, str]: + api = get_api(credentials, convert_urls=False) + + # GitHub API endpoint for submitting a review + submit_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/reviews/{review_id}/events" + + data = {"event": event.value} + + response = await api.post(submit_url, json=data) + review_data = response.json() + + return review_data["state"], review_data["html_url"] + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + state, html_url = await self.submit_review( + credentials, + input_data.repo, + input_data.pr_number, + input_data.review_id, + input_data.event, + ) + yield "state", state + yield "html_url", html_url + except Exception as e: + yield "error", str(e) + + +class GithubResolveReviewDiscussionBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo: str = SchemaField( + description="GitHub repository", + placeholder="owner/repo", + ) + pr_number: int = SchemaField( + description="Pull request number", + placeholder="123", + ) + comment_id: int = SchemaField( + description="ID of the review comment to resolve/unresolve", + placeholder="123456", + ) + resolve: bool = SchemaField( + description="Whether to resolve (true) or unresolve (false) the discussion", + default=True, + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the operation was successful") + error: str = SchemaField(description="Error message if the operation failed") + + def __init__(self): + super().__init__( + id="b4b8a38c-95ae-4c91-9ef8-c2cffaf2b5d1", + description="This block resolves or unresolves a review discussion thread on a GitHub pull request.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubResolveReviewDiscussionBlock.Input, + output_schema=GithubResolveReviewDiscussionBlock.Output, + test_input={ + "repo": "owner/repo", + "pr_number": 1, + "comment_id": 123456, + "resolve": True, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"resolve_discussion": lambda *args, **kwargs: True}, + ) + + @staticmethod + async def resolve_discussion( + credentials: GithubCredentials, + repo: str, + pr_number: int, + comment_id: int, + resolve: bool, + ) -> bool: + api = get_api(credentials, convert_urls=False) + + # Extract owner and repo name + parts = repo.split("/") + owner = parts[0] + repo_name = parts[1] + + # GitHub GraphQL API is needed for resolving/unresolving discussions + # First, we need to get the node ID of the comment + graphql_url = "https://api.github.com/graphql" + + # Query to get the review comment node ID + query = """ + query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviewThreads(first: 100) { + nodes { + comments(first: 100) { + nodes { + databaseId + id + } + } + id + isResolved + } + } + } + } + } + """ + + variables = {"owner": owner, "repo": repo_name, "number": pr_number} + + response = await api.post( + graphql_url, json={"query": query, "variables": variables} + ) + data = response.json() + + # Find the thread containing our comment + thread_id = None + for thread in data["data"]["repository"]["pullRequest"]["reviewThreads"][ + "nodes" + ]: + for comment in thread["comments"]["nodes"]: + if comment["databaseId"] == comment_id: + thread_id = thread["id"] + break + if thread_id: + break + + if not thread_id: + raise ValueError(f"Comment {comment_id} not found in pull request") + + # Now resolve or unresolve the thread + # GitHub's GraphQL API has separate mutations for resolve and unresolve + if resolve: + mutation = """ + mutation($threadId: ID!) { + resolveReviewThread(input: {threadId: $threadId}) { + thread { + isResolved + } + } + } + """ + else: + mutation = """ + mutation($threadId: ID!) { + unresolveReviewThread(input: {threadId: $threadId}) { + thread { + isResolved + } + } + } + """ + + mutation_variables = {"threadId": thread_id} + + response = await api.post( + graphql_url, json={"query": mutation, "variables": mutation_variables} + ) + result = response.json() + + if "errors" in result: + raise Exception(f"GraphQL error: {result['errors']}") + + return True + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = await self.resolve_discussion( + credentials, + input_data.repo, + input_data.pr_number, + input_data.comment_id, + input_data.resolve, + ) + yield "success", success + except Exception as e: + yield "success", False + yield "error", str(e) + + +class GithubGetPRReviewCommentsBlock(Block): + class Input(BlockSchema): + credentials: GithubCredentialsInput = GithubCredentialsField("repo") + repo: str = SchemaField( + description="GitHub repository", + placeholder="owner/repo", + ) + pr_number: int = SchemaField( + description="Pull request number", + placeholder="123", + ) + review_id: Optional[int] = SchemaField( + description="ID of a specific review to get comments from (optional)", + placeholder="123456", + default=None, + advanced=True, + ) + + class Output(BlockSchema): + class CommentItem(TypedDict): + id: int + user: str + body: str + path: str + line: int + side: str + created_at: str + updated_at: str + in_reply_to_id: Optional[int] + html_url: str + + comment: CommentItem = SchemaField( + title="Comment", + description="Individual review comment with details", + ) + comments: list[CommentItem] = SchemaField( + description="List of all review comments on the pull request" + ) + error: str = SchemaField(description="Error message if getting comments failed") + + def __init__(self): + super().__init__( + id="1d34db7f-10c1-45c1-9d43-749f743c8bd4", + description="This block gets all review comments from a GitHub pull request or from a specific review.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubGetPRReviewCommentsBlock.Input, + output_schema=GithubGetPRReviewCommentsBlock.Output, + test_input={ + "repo": "owner/repo", + "pr_number": 1, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "comments", + [ + { + "id": 123456, + "user": "reviewer1", + "body": "This needs improvement", + "path": "src/main.py", + "line": 42, + "side": "RIGHT", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + "in_reply_to_id": None, + "html_url": "https://github.com/owner/repo/pull/1#discussion_r123456", + } + ], + ), + ( + "comment", + { + "id": 123456, + "user": "reviewer1", + "body": "This needs improvement", + "path": "src/main.py", + "line": 42, + "side": "RIGHT", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + "in_reply_to_id": None, + "html_url": "https://github.com/owner/repo/pull/1#discussion_r123456", + }, + ), + ], + test_mock={ + "get_comments": lambda *args, **kwargs: [ + { + "id": 123456, + "user": "reviewer1", + "body": "This needs improvement", + "path": "src/main.py", + "line": 42, + "side": "RIGHT", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + "in_reply_to_id": None, + "html_url": "https://github.com/owner/repo/pull/1#discussion_r123456", + } + ] + }, + ) + + @staticmethod + async def get_comments( + credentials: GithubCredentials, + repo: str, + pr_number: int, + review_id: Optional[int] = None, + ) -> list[Output.CommentItem]: + api = get_api(credentials, convert_urls=False) + + # Determine the endpoint based on whether we want comments from a specific review + if review_id: + # Get comments from a specific review + comments_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/reviews/{review_id}/comments" + else: + # Get all review comments on the PR + comments_url = ( + f"https://api.github.com/repos/{repo}/pulls/{pr_number}/comments" + ) + + response = await api.get(comments_url) + data = response.json() + + comments: list[GithubGetPRReviewCommentsBlock.Output.CommentItem] = [ + { + "id": comment["id"], + "user": comment["user"]["login"], + "body": comment["body"], + "path": comment.get("path", ""), + "line": comment.get("line", 0), + "side": comment.get("side", ""), + "created_at": comment["created_at"], + "updated_at": comment["updated_at"], + "in_reply_to_id": comment.get("in_reply_to_id"), + "html_url": comment["html_url"], + } + for comment in data + ] + return comments + + async def run( + self, + input_data: Input, + *, + credentials: GithubCredentials, + **kwargs, + ) -> BlockOutput: + try: + comments = await self.get_comments( + credentials, + input_data.repo, + input_data.pr_number, + input_data.review_id, + ) + yield "comments", comments + for comment in comments: + yield "comment", comment + except Exception as e: + yield "error", str(e) + + +class GithubCreateCommentObjectBlock(Block): + class Input(BlockSchema): + path: str = SchemaField( + description="The file path to comment on", + placeholder="src/main.py", + ) + body: str = SchemaField( + description="The comment text", + placeholder="Please fix this issue", + ) + position: Optional[int] = SchemaField( + description="Position in the diff (line number from first @@ hunk). Use this OR line.", + placeholder="6", + default=None, + advanced=True, + ) + line: Optional[int] = SchemaField( + description="Line number in the file (will be used as position if position not provided)", + placeholder="42", + default=None, + advanced=True, + ) + side: Optional[str] = SchemaField( + description="Side of the diff to comment on (NOTE: Only for standalone comments, not review comments)", + default="RIGHT", + advanced=True, + ) + start_line: Optional[int] = SchemaField( + description="Start line for multi-line comments (NOTE: Only for standalone comments, not review comments)", + default=None, + advanced=True, + ) + start_side: Optional[str] = SchemaField( + description="Side for the start of multi-line comments (NOTE: Only for standalone comments, not review comments)", + default=None, + advanced=True, + ) + + class Output(BlockSchema): + comment_object: dict = SchemaField( + description="The comment object formatted for GitHub API" + ) + + def __init__(self): + super().__init__( + id="b7d5e4f2-8c3a-4e6b-9f1d-7a8b9c5e4d3f", + description="Creates a comment object for use with GitHub blocks. Note: For review comments, only path, body, and position are used. Side fields are only for standalone PR comments.", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubCreateCommentObjectBlock.Input, + output_schema=GithubCreateCommentObjectBlock.Output, + test_input={ + "path": "src/main.py", + "body": "Please fix this issue", + "position": 6, + }, + test_output=[ + ( + "comment_object", + { + "path": "src/main.py", + "body": "Please fix this issue", + "position": 6, + }, + ), + ], + ) + + async def run( + self, + input_data: Input, + **kwargs, + ) -> BlockOutput: + # Build the comment object + comment_obj: dict = { + "path": input_data.path, + "body": input_data.body, + } + + # Add position or line + if input_data.position is not None: + comment_obj["position"] = input_data.position + elif input_data.line is not None: + # Note: line will be used as position, which may not be accurate + # Position should be calculated from the diff + comment_obj["position"] = input_data.line + + # Add optional fields only if they differ from defaults or are explicitly provided + if input_data.side and input_data.side != "RIGHT": + comment_obj["side"] = input_data.side + if input_data.start_line is not None: + comment_obj["start_line"] = input_data.start_line + if input_data.start_side: + comment_obj["start_side"] = input_data.start_side + + yield "comment_object", comment_obj diff --git a/autogpt_platform/backend/backend/blocks/github/statuses.py b/autogpt_platform/backend/backend/blocks/github/statuses.py new file mode 100644 index 000000000000..a7e2b006aaa2 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/github/statuses.py @@ -0,0 +1,182 @@ +from enum import Enum +from typing import Optional + +from pydantic import BaseModel + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + +from ._api import get_api +from ._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + GithubFineGrainedAPICredentials, + GithubFineGrainedAPICredentialsField, + GithubFineGrainedAPICredentialsInput, +) + + +class StatusState(Enum): + ERROR = "error" + FAILURE = "failure" + PENDING = "pending" + SUCCESS = "success" + + +class GithubCreateStatusBlock(Block): + """Block for creating a commit status on a GitHub repository.""" + + class Input(BlockSchema): + credentials: GithubFineGrainedAPICredentialsInput = ( + GithubFineGrainedAPICredentialsField("repo:status") + ) + repo_url: str = SchemaField( + description="URL of the GitHub repository", + placeholder="https://github.com/owner/repo", + ) + sha: str = SchemaField( + description="The SHA of the commit to set status for", + ) + state: StatusState = SchemaField( + description="The state of the status (error, failure, pending, success)", + ) + target_url: Optional[str] = SchemaField( + description="URL with additional details about this status", + default=None, + ) + description: Optional[str] = SchemaField( + description="Short description of the status", + default=None, + ) + check_name: Optional[str] = SchemaField( + description="Label to differentiate this status from others", + default="AutoGPT Platform Checks", + advanced=False, + ) + + class Output(BlockSchema): + class StatusResult(BaseModel): + id: int + url: str + state: str + context: str + description: Optional[str] + target_url: Optional[str] + created_at: str + updated_at: str + + status: StatusResult = SchemaField(description="Details of the created status") + error: str = SchemaField(description="Error message if status creation failed") + + def __init__(self): + super().__init__( + id="3d67f123-a4b5-4c89-9d01-2e34f5c67890", # Generated UUID + description="Creates a new commit status in a GitHub repository", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=GithubCreateStatusBlock.Input, + output_schema=GithubCreateStatusBlock.Output, + test_input={ + "repo_url": "https://github.com/owner/repo", + "sha": "ce587453ced02b1526dfb4cb910479d431683101", + "state": StatusState.SUCCESS.value, + "target_url": "https://example.com/build/status", + "description": "The build succeeded!", + "check_name": "continuous-integration/jenkins", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "status", + { + "id": 1234567890, + "url": "https://api.github.com/repos/owner/repo/statuses/ce587453ced02b1526dfb4cb910479d431683101", + "state": "success", + "context": "continuous-integration/jenkins", + "description": "The build succeeded!", + "target_url": "https://example.com/build/status", + "created_at": "2024-01-21T10:00:00Z", + "updated_at": "2024-01-21T10:00:00Z", + }, + ), + ], + test_mock={ + "create_status": lambda *args, **kwargs: { + "id": 1234567890, + "url": "https://api.github.com/repos/owner/repo/statuses/ce587453ced02b1526dfb4cb910479d431683101", + "state": "success", + "context": "continuous-integration/jenkins", + "description": "The build succeeded!", + "target_url": "https://example.com/build/status", + "created_at": "2024-01-21T10:00:00Z", + "updated_at": "2024-01-21T10:00:00Z", + } + }, + ) + + @staticmethod + async def create_status( + credentials: GithubFineGrainedAPICredentials, + repo_url: str, + sha: str, + state: StatusState, + target_url: Optional[str] = None, + description: Optional[str] = None, + context: str = "default", + ) -> dict: + api = get_api(credentials) + + class StatusData(BaseModel): + state: str + target_url: Optional[str] = None + description: Optional[str] = None + context: str + + data = StatusData( + state=state.value, + context=context, + ) + + if target_url: + data.target_url = target_url + + if description: + data.description = description + + status_url = f"{repo_url}/statuses/{sha}" + response = await api.post( + status_url, data=data.model_dump_json(exclude_none=True) + ) + result = response.json() + + return { + "id": result["id"], + "url": result["url"], + "state": result["state"], + "context": result["context"], + "description": result.get("description"), + "target_url": result.get("target_url"), + "created_at": result["created_at"], + "updated_at": result["updated_at"], + } + + async def run( + self, + input_data: Input, + *, + credentials: GithubFineGrainedAPICredentials, + **kwargs, + ) -> BlockOutput: + try: + result = await self.create_status( + credentials=credentials, + repo_url=input_data.repo_url, + sha=input_data.sha, + state=input_data.state, + target_url=input_data.target_url, + description=input_data.description, + context=input_data.check_name or "AutoGPT Platform Checks", + ) + yield "status", result + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/github/triggers.py b/autogpt_platform/backend/backend/blocks/github/triggers.py index 938dce84faea..83b1689b8946 100644 --- a/autogpt_platform/backend/backend/blocks/github/triggers.py +++ b/autogpt_platform/backend/backend/blocks/github/triggers.py @@ -12,6 +12,7 @@ BlockWebhookConfig, ) from backend.data.model import SchemaField +from backend.integrations.providers import ProviderName from ._auth import ( TEST_CREDENTIALS, @@ -36,7 +37,7 @@ class Input(BlockSchema): placeholder="{owner}/{repo}", ) # --8<-- [start:example-payload-field] - payload: dict = SchemaField(hidden=True, default={}) + payload: dict = SchemaField(hidden=True, default_factory=dict) # --8<-- [end:example-payload-field] class Output(BlockSchema): @@ -52,7 +53,7 @@ class Output(BlockSchema): description="Error message if the payload could not be processed" ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: yield "payload", input_data.payload yield "triggered_by_user", input_data.payload["sender"] @@ -123,7 +124,7 @@ def __init__(self): output_schema=GithubPullRequestTriggerBlock.Output, # --8<-- [start:example-webhook_config] webhook_config=BlockWebhookConfig( - provider="github", + provider=ProviderName.GITHUB, webhook_type=GithubWebhookType.REPO, resource_format="{repo}", event_filter_input="events", @@ -147,8 +148,9 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore - yield from super().run(input_data, **kwargs) + async def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore + async for name, value in super().run(input_data, **kwargs): + yield name, value yield "event", input_data.payload["action"] yield "number", input_data.payload["number"] yield "pull_request", input_data.payload["pull_request"] diff --git a/autogpt_platform/backend/backend/blocks/google/calendar.py b/autogpt_platform/backend/backend/blocks/google/calendar.py new file mode 100644 index 000000000000..339daab43056 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/google/calendar.py @@ -0,0 +1,599 @@ +import asyncio +import enum +import uuid +from datetime import datetime, timedelta, timezone +from typing import Literal + +from google.oauth2.credentials import Credentials +from googleapiclient.discovery import build +from pydantic import BaseModel + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField +from backend.util.settings import Settings + +from ._auth import ( + GOOGLE_OAUTH_IS_CONFIGURED, + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + GoogleCredentials, + GoogleCredentialsField, + GoogleCredentialsInput, +) + +settings = Settings() + + +class CalendarEvent(BaseModel): + """Structured representation of a Google Calendar event.""" + + id: str + title: str + start_time: str + end_time: str + is_all_day: bool + location: str | None + description: str | None + organizer: str | None + attendees: list[str] + has_video_call: bool + video_link: str | None + calendar_link: str + is_recurring: bool + + +class GoogleCalendarReadEventsBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/calendar.readonly"] + ) + calendar_id: str = SchemaField( + description="Calendar ID (use 'primary' for your main calendar)", + default="primary", + ) + max_events: int = SchemaField( + description="Maximum number of events to retrieve", default=10 + ) + start_time: datetime = SchemaField( + description="Retrieve events starting from this time", + default_factory=lambda: datetime.now(tz=timezone.utc), + ) + time_range_days: int = SchemaField( + description="Number of days to look ahead for events", default=30 + ) + search_term: str | None = SchemaField( + description="Optional search term to filter events by", default=None + ) + + page_token: str | None = SchemaField( + description="Page token from previous request to get the next batch of events. You can use this if you have lots of events you want to process in a loop", + default=None, + ) + include_declined_events: bool = SchemaField( + description="Include events you've declined", default=False + ) + + class Output(BlockSchema): + events: list[CalendarEvent] = SchemaField( + description="List of calendar events in the requested time range", + default_factory=list, + ) + event: CalendarEvent = SchemaField( + description="One of the calendar events in the requested time range" + ) + next_page_token: str | None = SchemaField( + description="Token for retrieving the next page of events if more exist", + default=None, + ) + error: str = SchemaField( + description="Error message if the request failed", + ) + + def __init__(self): + # Create realistic test data for events + test_now = datetime.now(tz=timezone.utc) + test_tomorrow = test_now + timedelta(days=1) + + test_event_dict = { + "id": "event1id", + "title": "Team Meeting", + "start_time": test_tomorrow.strftime("%Y-%m-%d %H:%M"), + "end_time": (test_tomorrow + timedelta(hours=1)).strftime("%Y-%m-%d %H:%M"), + "is_all_day": False, + "location": "Conference Room A", + "description": "Weekly team sync", + "organizer": "manager@example.com", + "attendees": ["colleague1@example.com", "colleague2@example.com"], + "has_video_call": True, + "video_link": "https://meet.google.com/abc-defg-hij", + "calendar_link": "https://calendar.google.com/calendar/event?eid=event1id", + "is_recurring": True, + } + + super().__init__( + id="80bc3ed1-e9a4-449e-8163-a8fc86f74f6a", + description="Retrieves upcoming events from a Google Calendar with filtering options", + categories={BlockCategory.PRODUCTIVITY, BlockCategory.DATA}, + input_schema=GoogleCalendarReadEventsBlock.Input, + output_schema=GoogleCalendarReadEventsBlock.Output, + disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "calendar_id": "primary", + "max_events": 5, + "start_time": test_now.isoformat(), + "time_range_days": 7, + "search_term": None, + "include_declined_events": False, + "page_token": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("event", test_event_dict), + ("events", [test_event_dict]), + ], + test_mock={ + "_read_calendar": lambda *args, **kwargs: { + "items": [ + { + "id": "event1id", + "summary": "Team Meeting", + "start": { + "dateTime": test_tomorrow.isoformat(), + "timeZone": "UTC", + }, + "end": { + "dateTime": ( + test_tomorrow + timedelta(hours=1) + ).isoformat(), + "timeZone": "UTC", + }, + "location": "Conference Room A", + "description": "Weekly team sync", + "organizer": {"email": "manager@example.com"}, + "attendees": [ + {"email": "colleague1@example.com"}, + {"email": "colleague2@example.com"}, + ], + "conferenceData": { + "conferenceUrl": "https://meet.google.com/abc-defg-hij" + }, + "htmlLink": "https://calendar.google.com/calendar/event?eid=event1id", + "recurrence": ["RRULE:FREQ=WEEKLY;COUNT=10"], + } + ], + "nextPageToken": None, + }, + "_format_events": lambda *args, **kwargs: [test_event_dict], + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + try: + service = self._build_service(credentials, **kwargs) + + # Calculate end time based on start time and time range + end_time = input_data.start_time + timedelta( + days=input_data.time_range_days + ) + + # Call Google Calendar API + result = await asyncio.to_thread( + self._read_calendar, + service=service, + calendarId=input_data.calendar_id, + time_min=input_data.start_time.isoformat(), + time_max=end_time.isoformat(), + max_results=input_data.max_events, + single_events=True, + search_term=input_data.search_term, + show_deleted=False, + show_hidden=input_data.include_declined_events, + page_token=input_data.page_token, + ) + + # Format events into a user-friendly structure + formatted_events = self._format_events(result.get("items", [])) + + # Include next page token if available + if next_page_token := result.get("nextPageToken"): + yield "next_page_token", next_page_token + + for event in formatted_events: + yield "event", event + + yield "events", formatted_events + + except Exception as e: + yield "error", str(e) + + @staticmethod + def _build_service(credentials: GoogleCredentials, **kwargs): + creds = Credentials( + token=( + credentials.access_token.get_secret_value() + if credentials.access_token + else None + ), + refresh_token=( + credentials.refresh_token.get_secret_value() + if credentials.refresh_token + else None + ), + token_uri="https://oauth2.googleapis.com/token", + client_id=settings.secrets.google_client_id, + client_secret=settings.secrets.google_client_secret, + scopes=credentials.scopes, + ) + return build("calendar", "v3", credentials=creds) + + def _read_calendar( + self, + service, + calendarId: str, + time_min: str, + time_max: str, + max_results: int, + single_events: bool, + search_term: str | None = None, + show_deleted: bool = False, + show_hidden: bool = False, + page_token: str | None = None, + ) -> dict: + """Read calendar events with optional filtering.""" + calendar = service.events() + + # Build query parameters + params = { + "calendarId": calendarId, + "timeMin": time_min, + "timeMax": time_max, + "maxResults": max_results, + "singleEvents": single_events, + "orderBy": "startTime", + "showDeleted": show_deleted, + "showHiddenInvitations": show_hidden, + **({"pageToken": page_token} if page_token else {}), + } + + # Add search term if provided + if search_term: + params["q"] = search_term + + result = calendar.list(**params).execute() + return result + + def _format_events(self, events: list[dict]) -> list[CalendarEvent]: + """Format Google Calendar API events into user-friendly structure.""" + formatted_events = [] + + for event in events: + # Determine if all-day event + is_all_day = "date" in event.get("start", {}) + + # Format start and end times + if is_all_day: + start_time = event.get("start", {}).get("date", "") + end_time = event.get("end", {}).get("date", "") + else: + # Convert ISO format to more readable format + start_datetime = datetime.fromisoformat( + event.get("start", {}).get("dateTime", "").replace("Z", "+00:00") + ) + end_datetime = datetime.fromisoformat( + event.get("end", {}).get("dateTime", "").replace("Z", "+00:00") + ) + start_time = start_datetime.strftime("%Y-%m-%d %H:%M") + end_time = end_datetime.strftime("%Y-%m-%d %H:%M") + + # Extract attendees + attendees = [] + for attendee in event.get("attendees", []): + if email := attendee.get("email"): + attendees.append(email) + + # Check for video call link + has_video_call = False + video_link = None + if conf_data := event.get("conferenceData"): + if conf_url := conf_data.get("conferenceUrl"): + has_video_call = True + video_link = conf_url + elif entry_points := conf_data.get("entryPoints", []): + for entry in entry_points: + if entry.get("entryPointType") == "video": + has_video_call = True + video_link = entry.get("uri") + break + + # Create formatted event + formatted_event = CalendarEvent( + id=event.get("id", ""), + title=event.get("summary", "Untitled Event"), + start_time=start_time, + end_time=end_time, + is_all_day=is_all_day, + location=event.get("location"), + description=event.get("description"), + organizer=event.get("organizer", {}).get("email"), + attendees=attendees, + has_video_call=has_video_call, + video_link=video_link, + calendar_link=event.get("htmlLink", ""), + is_recurring=bool(event.get("recurrence")), + ) + + formatted_events.append(formatted_event) + + return formatted_events + + +class ReminderPreset(enum.Enum): + """Common reminder times before an event.""" + + TEN_MINUTES = 10 + THIRTY_MINUTES = 30 + ONE_HOUR = 60 + ONE_DAY = 1440 # 24 hours in minutes + + +class RecurrenceFrequency(enum.Enum): + """Frequency options for recurring events.""" + + DAILY = "DAILY" + WEEKLY = "WEEKLY" + MONTHLY = "MONTHLY" + YEARLY = "YEARLY" + + +class ExactTiming(BaseModel): + """Model for specifying start and end times.""" + + discriminator: Literal["exact_timing"] + start_datetime: datetime + end_datetime: datetime + + +class DurationTiming(BaseModel): + """Model for specifying start time and duration.""" + + discriminator: Literal["duration_timing"] + start_datetime: datetime + duration_minutes: int + + +class OneTimeEvent(BaseModel): + """Model for a one-time event.""" + + discriminator: Literal["one_time"] + + +class RecurringEvent(BaseModel): + """Model for a recurring event.""" + + discriminator: Literal["recurring"] + frequency: RecurrenceFrequency + count: int + + +class GoogleCalendarCreateEventBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/calendar"] + ) + # Event Details + event_title: str = SchemaField(description="Title of the event") + location: str | None = SchemaField( + description="Location of the event", default=None + ) + description: str | None = SchemaField( + description="Description of the event", default=None + ) + + # Timing + timing: ExactTiming | DurationTiming = SchemaField( + discriminator="discriminator", + advanced=False, + description="Specify when the event starts and ends", + default_factory=lambda: DurationTiming( + discriminator="duration_timing", + start_datetime=datetime.now().replace(microsecond=0, second=0, minute=0) + + timedelta(hours=1), + duration_minutes=60, + ), + ) + + # Calendar selection + calendar_id: str = SchemaField( + description="Calendar ID (use 'primary' for your main calendar)", + default="primary", + ) + + # Guests + guest_emails: list[str] = SchemaField( + description="Email addresses of guests to invite", default_factory=list + ) + send_notifications: bool = SchemaField( + description="Send email notifications to guests", default=True + ) + + # Extras + add_google_meet: bool = SchemaField( + description="Include a Google Meet video conference link", default=False + ) + recurrence: OneTimeEvent | RecurringEvent = SchemaField( + discriminator="discriminator", + description="Whether the event repeats", + default_factory=lambda: OneTimeEvent(discriminator="one_time"), + ) + reminder_minutes: list[ReminderPreset] = SchemaField( + description="When to send reminders before the event", + default_factory=lambda: [ReminderPreset.TEN_MINUTES], + ) + + class Output(BlockSchema): + event_id: str = SchemaField(description="ID of the created event") + event_link: str = SchemaField( + description="Link to view the event in Google Calendar" + ) + error: str = SchemaField(description="Error message if event creation failed") + + def __init__(self): + super().__init__( + id="ed2ec950-fbff-4204-94c0-023fb1d625e0", + description="This block creates a new event in Google Calendar with customizable parameters.", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=GoogleCalendarCreateEventBlock.Input, + output_schema=GoogleCalendarCreateEventBlock.Output, + disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "event_title": "Team Meeting", + "location": "Conference Room A", + "description": "Weekly team sync-up", + "calendar_id": "primary", + "guest_emails": ["colleague1@example.com", "colleague2@example.com"], + "add_google_meet": True, + "send_notifications": True, + "reminder_minutes": [ + ReminderPreset.TEN_MINUTES.value, + ReminderPreset.ONE_HOUR.value, + ], + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("event_id", "abc123event_id"), + ("event_link", "https://calendar.google.com/calendar/event?eid=abc123"), + ], + test_mock={ + "_create_event": lambda *args, **kwargs: { + "id": "abc123event_id", + "htmlLink": "https://calendar.google.com/calendar/event?eid=abc123", + } + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + try: + service = self._build_service(credentials, **kwargs) + + # Create event body + # Get start and end times based on the timing option + if input_data.timing.discriminator == "exact_timing": + start_datetime = input_data.timing.start_datetime + end_datetime = input_data.timing.end_datetime + else: # duration_timing + start_datetime = input_data.timing.start_datetime + end_datetime = start_datetime + timedelta( + minutes=input_data.timing.duration_minutes + ) + + # Format datetimes for Google Calendar API + start_time_str = start_datetime.isoformat() + end_time_str = end_datetime.isoformat() + + # Build the event body + event_body = { + "summary": input_data.event_title, + "start": {"dateTime": start_time_str}, + "end": {"dateTime": end_time_str}, + } + + # Add optional fields + if input_data.location: + event_body["location"] = input_data.location + + if input_data.description: + event_body["description"] = input_data.description + + # Add guests + if input_data.guest_emails: + event_body["attendees"] = [ + {"email": email} for email in input_data.guest_emails + ] + + # Add reminders + if input_data.reminder_minutes: + event_body["reminders"] = { + "useDefault": False, + "overrides": [ + {"method": "popup", "minutes": reminder.value} + for reminder in input_data.reminder_minutes + ], + } + + # Add Google Meet + if input_data.add_google_meet: + event_body["conferenceData"] = { + "createRequest": { + "requestId": f"meet-{uuid.uuid4()}", + "conferenceSolutionKey": {"type": "hangoutsMeet"}, + } + } + + # Add recurrence + if input_data.recurrence.discriminator == "recurring": + rule = f"RRULE:FREQ={input_data.recurrence.frequency.value}" + rule += f";COUNT={input_data.recurrence.count}" + event_body["recurrence"] = [rule] + + # Create the event + result = await asyncio.to_thread( + self._create_event, + service=service, + calendar_id=input_data.calendar_id, + event_body=event_body, + send_notifications=input_data.send_notifications, + conference_data_version=1 if input_data.add_google_meet else 0, + ) + + yield "event_id", result["id"] + yield "event_link", result["htmlLink"] + + except Exception as e: + yield "error", str(e) + + @staticmethod + def _build_service(credentials: GoogleCredentials, **kwargs): + creds = Credentials( + token=( + credentials.access_token.get_secret_value() + if credentials.access_token + else None + ), + refresh_token=( + credentials.refresh_token.get_secret_value() + if credentials.refresh_token + else None + ), + token_uri="https://oauth2.googleapis.com/token", + client_id=settings.secrets.google_client_id, + client_secret=settings.secrets.google_client_secret, + scopes=credentials.scopes, + ) + return build("calendar", "v3", credentials=creds) + + def _create_event( + self, + service, + calendar_id: str, + event_body: dict, + send_notifications: bool = False, + conference_data_version: int = 0, + ) -> dict: + """Create a new event in Google Calendar.""" + calendar = service.events() + + # Make the API call + result = calendar.insert( + calendarId=calendar_id, + body=event_body, + sendNotifications=send_notifications, + conferenceDataVersion=conference_data_version, + ).execute() + + return result diff --git a/autogpt_platform/backend/backend/blocks/google/gmail.py b/autogpt_platform/backend/backend/blocks/google/gmail.py index d0168e4a82be..1b1fb5eb74b4 100644 --- a/autogpt_platform/backend/backend/blocks/google/gmail.py +++ b/autogpt_platform/backend/backend/blocks/google/gmail.py @@ -1,13 +1,23 @@ +import asyncio import base64 -from email.utils import parseaddr -from typing import List +from abc import ABC +from email import encoders +from email.mime.base import MIMEBase +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.policy import SMTP +from email.utils import getaddresses, parseaddr +from pathlib import Path +from typing import List, Literal, Optional from google.oauth2.credentials import Credentials from googleapiclient.discovery import build -from pydantic import BaseModel +from pydantic import BaseModel, Field from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField +from backend.util.file import MediaFileType, get_exec_file_path, store_media_file +from backend.util.settings import Settings from ._auth import ( GOOGLE_OAUTH_IS_CONFIGURED, @@ -18,6 +28,107 @@ GoogleCredentialsInput, ) +settings = Settings() + +# No-wrap policy for plain text emails to prevent 78-char hard-wrap +NO_WRAP_POLICY = SMTP.clone(max_line_length=0) + + +def serialize_email_recipients(recipients: list[str]) -> str: + """Serialize recipients list to comma-separated string.""" + return ", ".join(recipients) + + +def _make_mime_text( + body: str, + content_type: Optional[Literal["auto", "plain", "html"]] = None, +) -> MIMEText: + """Create a MIMEText object with proper content type and no hard-wrap for plain text. + + This function addresses the common Gmail issue where plain text emails are + hard-wrapped at 78 characters, creating awkward narrow columns in modern + email clients. It also ensures HTML emails are properly identified and sent + with the correct MIME type. + + Args: + body: The email body content (plain text or HTML) + content_type: The content type - "auto" (default), "plain", or "html" + - "auto" or None: Auto-detects based on presence of HTML tags + - "plain": Forces plain text format without line wrapping + - "html": Forces HTML format with standard wrapping + + Returns: + MIMEText object configured with: + - Appropriate content subtype (plain or html) + - UTF-8 charset for proper Unicode support + - No-wrap policy for plain text (max_line_length=0) + - Standard wrapping for HTML content + + Examples: + >>> # Plain text email without wrapping + >>> mime = _make_mime_text("Long paragraph...", "plain") + >>> # HTML email with auto-detection + >>> mime = _make_mime_text("

Hello

", "auto") + """ + # Auto-detect content type if not specified or "auto" + if content_type is None or content_type == "auto": + # Simple heuristic: check for HTML tags in first 500 chars + looks_html = "<" in body[:500] and ">" in body[:500] + actual_type = "html" if looks_html else "plain" + else: + actual_type = content_type + + # Create MIMEText with appropriate settings + if actual_type == "html": + # HTML content - normal wrapping is OK + return MIMEText(body, _subtype="html", _charset="utf-8") + else: + # Plain text - use no-wrap policy to prevent 78-char hard-wrap + return MIMEText(body, _subtype="plain", _charset="utf-8", policy=NO_WRAP_POLICY) + + +async def create_mime_message( + input_data, + graph_exec_id: str, + user_id: str, +) -> str: + """Create a MIME message with attachments and return base64-encoded raw message.""" + + message = MIMEMultipart() + message["to"] = serialize_email_recipients(input_data.to) + message["subject"] = input_data.subject + + if input_data.cc: + message["cc"] = ", ".join(input_data.cc) + if input_data.bcc: + message["bcc"] = ", ".join(input_data.bcc) + + # Use the new helper function with content_type if available + content_type = getattr(input_data, "content_type", None) + message.attach(_make_mime_text(input_data.body, content_type)) + + # Handle attachments if any + if input_data.attachments: + for attach in input_data.attachments: + local_path = await store_media_file( + user_id=user_id, + graph_exec_id=graph_exec_id, + file=attach, + return_content=False, + ) + abs_path = get_exec_file_path(graph_exec_id, local_path) + part = MIMEBase("application", "octet-stream") + with open(abs_path, "rb") as f: + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", + f"attachment; filename={Path(abs_path).name}", + ) + message.attach(part) + + return base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8") + class Attachment(BaseModel): filename: str @@ -27,18 +138,188 @@ class Attachment(BaseModel): class Email(BaseModel): + threadId: str + labelIds: list[str] id: str subject: str snippet: str from_: str - to: str + to: list[str] # List of recipient email addresses + cc: list[str] = Field(default_factory=list) # CC recipients + bcc: list[str] = Field( + default_factory=list + ) # BCC recipients (rarely available in received emails) date: str body: str = "" # Default to an empty string sizeEstimate: int attachments: List[Attachment] -class GmailReadBlock(Block): +class Thread(BaseModel): + id: str + messages: list[Email] + historyId: str + + +class GmailSendResult(BaseModel): + id: str + status: str + + +class GmailDraftResult(BaseModel): + id: str + message_id: str + status: str + + +class GmailLabelResult(BaseModel): + label_id: str + status: str + + +class Profile(BaseModel): + emailAddress: str + messagesTotal: int + threadsTotal: int + historyId: str + + +class GmailBase(Block, ABC): + """Base class for Gmail blocks with common functionality.""" + + def _build_service(self, credentials: GoogleCredentials, **kwargs): + creds = Credentials( + token=( + credentials.access_token.get_secret_value() + if credentials.access_token + else None + ), + refresh_token=( + credentials.refresh_token.get_secret_value() + if credentials.refresh_token + else None + ), + token_uri="https://oauth2.googleapis.com/token", + client_id=settings.secrets.google_client_id, + client_secret=settings.secrets.google_client_secret, + scopes=credentials.scopes, + ) + return build("gmail", "v1", credentials=creds) + + async def _get_email_body(self, msg, service): + """Extract email body content with support for multipart messages and HTML conversion.""" + text = await self._walk_for_body(msg["payload"], msg["id"], service) + return text or "This email does not contain a readable body." + + async def _walk_for_body(self, part, msg_id, service, depth=0): + """Recursively walk through email parts to find readable body content.""" + # Prevent infinite recursion by limiting depth + if depth > 10: + return None + + mime_type = part.get("mimeType", "") + body = part.get("body", {}) + + # Handle text/plain content + if mime_type == "text/plain" and body.get("data"): + return self._decode_base64(body["data"]) + + # Handle text/html content (convert to plain text) + if mime_type == "text/html" and body.get("data"): + html_content = self._decode_base64(body["data"]) + if html_content: + try: + import html2text + + h = html2text.HTML2Text() + h.ignore_links = False + h.ignore_images = True + return h.handle(html_content) + except ImportError: + # Fallback: return raw HTML if html2text is not available + return html_content + + # Handle content stored as attachment + if body.get("attachmentId"): + attachment_data = await self._download_attachment_body( + body["attachmentId"], msg_id, service + ) + if attachment_data: + return self._decode_base64(attachment_data) + + # Recursively search in parts + for sub_part in part.get("parts", []): + text = await self._walk_for_body(sub_part, msg_id, service, depth + 1) + if text: + return text + + return None + + def _decode_base64(self, data): + """Safely decode base64 URL-safe data with proper padding.""" + if not data: + return None + try: + # Add padding if necessary + missing_padding = len(data) % 4 + if missing_padding: + data += "=" * (4 - missing_padding) + return base64.urlsafe_b64decode(data).decode("utf-8") + except Exception: + return None + + async def _download_attachment_body(self, attachment_id, msg_id, service): + """Download attachment content when email body is stored as attachment.""" + try: + attachment = await asyncio.to_thread( + lambda: service.users() + .messages() + .attachments() + .get(userId="me", messageId=msg_id, id=attachment_id) + .execute() + ) + return attachment.get("data") + except Exception: + return None + + async def _get_attachments(self, service, message): + attachments = [] + if "parts" in message["payload"]: + for part in message["payload"]["parts"]: + if part.get("filename"): + attachment = Attachment( + filename=part["filename"], + content_type=part["mimeType"], + size=int(part["body"].get("size", 0)), + attachment_id=part["body"]["attachmentId"], + ) + attachments.append(attachment) + return attachments + + async def download_attachment(self, service, message_id: str, attachment_id: str): + attachment = await asyncio.to_thread( + lambda: service.users() + .messages() + .attachments() + .get(userId="me", messageId=message_id, id=attachment_id) + .execute() + ) + file_data = base64.urlsafe_b64decode(attachment["data"].encode("UTF-8")) + return file_data + + async def _get_label_id(self, service, label_name: str) -> str | None: + """Get label ID by name from Gmail.""" + results = await asyncio.to_thread( + lambda: service.users().labels().list(userId="me").execute() + ) + labels = results.get("labels", []) + for label in labels: + if label["name"] == label_name: + return label["id"] + return None + + +class GmailReadBlock(GmailBase): class Input(BlockSchema): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/gmail.readonly"] @@ -81,11 +362,15 @@ def __init__(self): ( "email", { + "threadId": "t1", + "labelIds": ["INBOX"], "id": "1", "subject": "Test Email", "snippet": "This is a test email", "from_": "test@example.com", - "to": "recipient@example.com", + "to": ["recipient@example.com"], + "cc": [], + "bcc": [], "date": "2024-01-01", "body": "This is a test email", "sizeEstimate": 100, @@ -96,11 +381,15 @@ def __init__(self): "emails", [ { + "threadId": "t1", + "labelIds": ["INBOX"], "id": "1", "subject": "Test Email", "snippet": "This is a test email", "from_": "test@example.com", - "to": "recipient@example.com", + "to": ["recipient@example.com"], + "cc": [], + "bcc": [], "date": "2024-01-01", "body": "This is a test email", "sizeEstimate": 100, @@ -112,11 +401,15 @@ def __init__(self): test_mock={ "_read_emails": lambda *args, **kwargs: [ { + "threadId": "t1", + "labelIds": ["INBOX"], "id": "1", "subject": "Test Email", "snippet": "This is a test email", "from_": "test@example.com", - "to": "recipient@example.com", + "to": ["recipient@example.com"], + "cc": [], + "bcc": [], "date": "2024-01-01", "body": "This is a test email", "sizeEstimate": 100, @@ -127,52 +420,49 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: service = self._build_service(credentials, **kwargs) - messages = self._read_emails(service, input_data.query, input_data.max_results) + messages = await self._read_emails( + service, + input_data.query, + input_data.max_results, + credentials.scopes, + ) for email in messages: yield "email", email yield "emails", messages - @staticmethod - def _build_service(credentials: GoogleCredentials, **kwargs): - creds = Credentials( - token=( - credentials.access_token.get_secret_value() - if credentials.access_token - else None - ), - refresh_token=( - credentials.refresh_token.get_secret_value() - if credentials.refresh_token - else None - ), - token_uri="https://oauth2.googleapis.com/token", - client_id=kwargs.get("client_id"), - client_secret=kwargs.get("client_secret"), - scopes=credentials.scopes, - ) - return build("gmail", "v1", credentials=creds) - - def _read_emails( - self, service, query: str | None, max_results: int | None + async def _read_emails( + self, + service, + query: str | None, + max_results: int | None, + scopes: list[str] | None, ) -> list[Email]: - results = ( - service.users() - .messages() - .list(userId="me", q=query or "", maxResults=max_results or 10) - .execute() + scopes = [s.lower() for s in (scopes or [])] + list_kwargs = {"userId": "me", "maxResults": max_results or 10} + if query and "https://www.googleapis.com/auth/gmail.metadata" not in scopes: + list_kwargs["q"] = query + + results = await asyncio.to_thread( + lambda: service.users().messages().list(**list_kwargs).execute() ) + messages = results.get("messages", []) email_data = [] for message in messages: - msg = ( - service.users() + format_type = ( + "metadata" + if "https://www.googleapis.com/auth/gmail.metadata" in scopes + else "full" + ) + msg = await asyncio.to_thread( + lambda: service.users() .messages() - .get(userId="me", id=message["id"], format="full") + .get(userId="me", id=message["id"], format=format_type) .execute() ) @@ -181,81 +471,77 @@ def _read_emails( for header in msg["payload"]["headers"] } - attachments = self._get_attachments(service, msg) + attachments = await self._get_attachments(service, msg) + + # Parse all recipients + to_recipients = [ + addr.strip() for _, addr in getaddresses([headers.get("to", "")]) + ] + cc_recipients = [ + addr.strip() for _, addr in getaddresses([headers.get("cc", "")]) + ] + bcc_recipients = [ + addr.strip() for _, addr in getaddresses([headers.get("bcc", "")]) + ] email = Email( + threadId=msg.get("threadId", None), + labelIds=msg.get("labelIds", []), id=msg["id"], subject=headers.get("subject", "No Subject"), - snippet=msg["snippet"], + snippet=msg.get("snippet", ""), from_=parseaddr(headers.get("from", ""))[1], - to=parseaddr(headers.get("to", ""))[1], + to=to_recipients if to_recipients else [], + cc=cc_recipients, + bcc=bcc_recipients, date=headers.get("date", ""), - body=self._get_email_body(msg), - sizeEstimate=msg["sizeEstimate"], + body=await self._get_email_body(msg, service), + sizeEstimate=msg.get("sizeEstimate", 0), attachments=attachments, ) email_data.append(email) return email_data - def _get_email_body(self, msg): - if "parts" in msg["payload"]: - for part in msg["payload"]["parts"]: - if part["mimeType"] == "text/plain": - return base64.urlsafe_b64decode(part["body"]["data"]).decode( - "utf-8" - ) - elif msg["payload"]["mimeType"] == "text/plain": - return base64.urlsafe_b64decode(msg["payload"]["body"]["data"]).decode( - "utf-8" - ) - - return "This email does not contain a text body." - - def _get_attachments(self, service, message): - attachments = [] - if "parts" in message["payload"]: - for part in message["payload"]["parts"]: - if part["filename"]: - attachment = Attachment( - filename=part["filename"], - content_type=part["mimeType"], - size=int(part["body"].get("size", 0)), - attachment_id=part["body"]["attachmentId"], - ) - attachments.append(attachment) - return attachments - # Add a new method to download attachment content - def download_attachment(self, service, message_id: str, attachment_id: str): - attachment = ( - service.users() - .messages() - .attachments() - .get(userId="me", messageId=message_id, id=attachment_id) - .execute() - ) - file_data = base64.urlsafe_b64decode(attachment["data"].encode("UTF-8")) - return file_data +class GmailSendBlock(GmailBase): + """ + Sends emails through Gmail with intelligent content type detection. + Features: + - Automatic HTML detection: Emails containing HTML tags are sent as text/html + - No hard-wrap for plain text: Plain text emails preserve natural line flow + - Manual content type override: Use content_type parameter to force specific format + - Full Unicode/emoji support with UTF-8 encoding + - Attachment support for multiple files + """ -class GmailSendBlock(Block): class Input(BlockSchema): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/gmail.send"] ) - to: str = SchemaField( - description="Recipient email address", + to: list[str] = SchemaField( + description="Recipient email addresses", ) subject: str = SchemaField( description="Email subject", ) body: str = SchemaField( - description="Email body", + description="Email body (plain text or HTML)", + ) + cc: list[str] = SchemaField(description="CC recipients", default_factory=list) + bcc: list[str] = SchemaField(description="BCC recipients", default_factory=list) + content_type: Optional[Literal["auto", "plain", "html"]] = SchemaField( + description="Content type: 'auto' (default - detects HTML), 'plain', or 'html'", + default=None, + advanced=True, + ) + attachments: list[MediaFileType] = SchemaField( + description="Files to attach", default_factory=list, advanced=True ) class Output(BlockSchema): - result: dict = SchemaField( + result: GmailSendResult = SchemaField( description="Send confirmation", ) error: str = SchemaField( @@ -265,13 +551,13 @@ class Output(BlockSchema): def __init__(self): super().__init__( id="6c27abc2-e51d-499e-a85f-5a0041ba94f0", - description="This block sends an email using Gmail.", + description="Send emails via Gmail with automatic HTML detection and proper text formatting. Plain text emails are sent without 78-character line wrapping, preserving natural paragraph flow. HTML emails are automatically detected and sent with correct MIME type.", categories={BlockCategory.COMMUNICATION}, input_schema=GmailSendBlock.Input, output_schema=GmailSendBlock.Output, disabled=not GOOGLE_OAUTH_IS_CONFIGURED, test_input={ - "to": "recipient@example.com", + "to": ["recipient@example.com"], "subject": "Test Email", "body": "This is a test email sent from GmailSendBlock.", "credentials": TEST_CREDENTIALS_INPUT, @@ -285,36 +571,156 @@ def __init__(self): }, ) - def run( - self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + async def run( + self, + input_data: Input, + *, + credentials: GoogleCredentials, + graph_exec_id: str, + user_id: str, + **kwargs, ) -> BlockOutput: - service = GmailReadBlock._build_service(credentials, **kwargs) - send_result = self._send_email( - service, input_data.to, input_data.subject, input_data.body + service = self._build_service(credentials, **kwargs) + result = await self._send_email( + service, + input_data, + graph_exec_id, + user_id, ) - yield "result", send_result + yield "result", result - def _send_email(self, service, to: str, subject: str, body: str) -> dict: - if not to or not subject or not body: - raise ValueError("To, subject, and body are required for sending an email") - message = self._create_message(to, subject, body) - sent_message = ( - service.users().messages().send(userId="me", body=message).execute() + async def _send_email( + self, service, input_data: Input, graph_exec_id: str, user_id: str + ) -> dict: + if not input_data.to or not input_data.subject or not input_data.body: + raise ValueError( + "At least one recipient, subject, and body are required for sending an email" + ) + raw_message = await create_mime_message(input_data, graph_exec_id, user_id) + sent_message = await asyncio.to_thread( + lambda: service.users() + .messages() + .send(userId="me", body={"raw": raw_message}) + .execute() ) return {"id": sent_message["id"], "status": "sent"} - def _create_message(self, to: str, subject: str, body: str) -> dict: - import base64 - from email.mime.text import MIMEText - message = MIMEText(body) - message["to"] = to - message["subject"] = subject - raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode("utf-8") - return {"raw": raw_message} +class GmailCreateDraftBlock(GmailBase): + """ + Creates draft emails in Gmail with intelligent content type detection. + Features: + - Automatic HTML detection: Drafts containing HTML tags are formatted as text/html + - No hard-wrap for plain text: Plain text drafts preserve natural line flow + - Manual content type override: Use content_type parameter to force specific format + - Full Unicode/emoji support with UTF-8 encoding + - Attachment support for multiple files + """ -class GmailListLabelsBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/gmail.modify"] + ) + to: list[str] = SchemaField( + description="Recipient email addresses", + ) + subject: str = SchemaField( + description="Email subject", + ) + body: str = SchemaField( + description="Email body (plain text or HTML)", + ) + cc: list[str] = SchemaField(description="CC recipients", default_factory=list) + bcc: list[str] = SchemaField(description="BCC recipients", default_factory=list) + content_type: Optional[Literal["auto", "plain", "html"]] = SchemaField( + description="Content type: 'auto' (default - detects HTML), 'plain', or 'html'", + default=None, + advanced=True, + ) + attachments: list[MediaFileType] = SchemaField( + description="Files to attach", default_factory=list, advanced=True + ) + + class Output(BlockSchema): + result: GmailDraftResult = SchemaField( + description="Draft creation result", + ) + error: str = SchemaField( + description="Error message if any", + ) + + def __init__(self): + super().__init__( + id="e1eeead4-46cb-491e-8281-17b6b9c44a55", + description="Create draft emails in Gmail with automatic HTML detection and proper text formatting. Plain text drafts preserve natural paragraph flow without 78-character line wrapping. HTML content is automatically detected and formatted correctly.", + categories={BlockCategory.COMMUNICATION}, + input_schema=GmailCreateDraftBlock.Input, + output_schema=GmailCreateDraftBlock.Output, + disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + test_input={ + "to": ["recipient@example.com"], + "subject": "Draft Test Email", + "body": "This is a test draft email.", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "result", + GmailDraftResult( + id="draft1", message_id="msg1", status="draft_created" + ), + ), + ], + test_mock={ + "_create_draft": lambda *args, **kwargs: { + "id": "draft1", + "message": {"id": "msg1"}, + }, + }, + ) + + async def run( + self, + input_data: Input, + *, + credentials: GoogleCredentials, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + service = self._build_service(credentials, **kwargs) + result = await self._create_draft( + service, + input_data, + graph_exec_id, + user_id, + ) + yield "result", GmailDraftResult( + id=result["id"], message_id=result["message"]["id"], status="draft_created" + ) + + async def _create_draft( + self, service, input_data: Input, graph_exec_id: str, user_id: str + ) -> dict: + if not input_data.to or not input_data.subject: + raise ValueError( + "At least one recipient and subject are required for creating a draft" + ) + + raw_message = await create_mime_message(input_data, graph_exec_id, user_id) + draft = await asyncio.to_thread( + lambda: service.users() + .drafts() + .create(userId="me", body={"message": {"raw": raw_message}}) + .execute() + ) + + return draft + + +class GmailListLabelsBlock(GmailBase): class Input(BlockSchema): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/gmail.labels"] @@ -357,20 +763,22 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = GmailReadBlock._build_service(credentials, **kwargs) - labels = self._list_labels(service) - yield "result", labels + service = self._build_service(credentials, **kwargs) + result = await self._list_labels(service) + yield "result", result - def _list_labels(self, service) -> list[dict]: - results = service.users().labels().list(userId="me").execute() + async def _list_labels(self, service) -> list[dict]: + results = await asyncio.to_thread( + lambda: service.users().labels().list(userId="me").execute() + ) labels = results.get("labels", []) return [{"id": label["id"], "name": label["name"]} for label in labels] -class GmailAddLabelBlock(Block): +class GmailAddLabelBlock(GmailBase): class Input(BlockSchema): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/gmail.modify"] @@ -383,7 +791,7 @@ class Input(BlockSchema): ) class Output(BlockSchema): - result: dict = SchemaField( + result: GmailLabelResult = SchemaField( description="Label addition result", ) error: str = SchemaField( @@ -418,25 +826,36 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = GmailReadBlock._build_service(credentials, **kwargs) - result = self._add_label(service, input_data.message_id, input_data.label_name) + service = self._build_service(credentials, **kwargs) + result = await self._add_label( + service, input_data.message_id, input_data.label_name + ) yield "result", result - def _add_label(self, service, message_id: str, label_name: str) -> dict: - label_id = self._get_or_create_label(service, label_name) - service.users().messages().modify( - userId="me", id=message_id, body={"addLabelIds": [label_id]} - ).execute() + async def _add_label(self, service, message_id: str, label_name: str) -> dict: + label_id = await self._get_or_create_label(service, label_name) + result = await asyncio.to_thread( + lambda: service.users() + .messages() + .modify(userId="me", id=message_id, body={"addLabelIds": [label_id]}) + .execute() + ) + if not result.get("labelIds"): + return { + "status": "Label already applied or not found", + "label_id": label_id, + } + return {"status": "Label added successfully", "label_id": label_id} - def _get_or_create_label(self, service, label_name: str) -> str: - label_id = self._get_label_id(service, label_name) + async def _get_or_create_label(self, service, label_name: str) -> str: + label_id = await self._get_label_id(service, label_name) if not label_id: - label = ( - service.users() + label = await asyncio.to_thread( + lambda: service.users() .labels() .create(userId="me", body={"name": label_name}) .execute() @@ -444,16 +863,8 @@ def _get_or_create_label(self, service, label_name: str) -> str: label_id = label["id"] return label_id - def _get_label_id(self, service, label_name: str) -> str | None: - results = service.users().labels().list(userId="me").execute() - labels = results.get("labels", []) - for label in labels: - if label["name"] == label_name: - return label["id"] - return None - -class GmailRemoveLabelBlock(Block): +class GmailRemoveLabelBlock(GmailBase): class Input(BlockSchema): credentials: GoogleCredentialsInput = GoogleCredentialsField( ["https://www.googleapis.com/auth/gmail.modify"] @@ -466,7 +877,7 @@ class Input(BlockSchema): ) class Output(BlockSchema): - result: dict = SchemaField( + result: GmailLabelResult = SchemaField( description="Label removal result", ) error: str = SchemaField( @@ -501,29 +912,676 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = GmailReadBlock._build_service(credentials, **kwargs) - result = self._remove_label( + service = self._build_service(credentials, **kwargs) + result = await self._remove_label( service, input_data.message_id, input_data.label_name ) yield "result", result - def _remove_label(self, service, message_id: str, label_name: str) -> dict: - label_id = self._get_label_id(service, label_name) + async def _remove_label(self, service, message_id: str, label_name: str) -> dict: + label_id = await self._get_label_id(service, label_name) if label_id: - service.users().messages().modify( - userId="me", id=message_id, body={"removeLabelIds": [label_id]} - ).execute() + result = await asyncio.to_thread( + lambda: service.users() + .messages() + .modify(userId="me", id=message_id, body={"removeLabelIds": [label_id]}) + .execute() + ) + if not result.get("labelIds"): + return { + "status": "Label already removed or not applied", + "label_id": label_id, + } return {"status": "Label removed successfully", "label_id": label_id} else: return {"status": "Label not found", "label_name": label_name} - def _get_label_id(self, service, label_name: str) -> str | None: - results = service.users().labels().list(userId="me").execute() - labels = results.get("labels", []) - for label in labels: - if label["name"] == label_name: - return label["id"] - return None + +class GmailGetThreadBlock(GmailBase): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/gmail.readonly"] + ) + threadId: str = SchemaField(description="Gmail thread ID") + + class Output(BlockSchema): + thread: Thread = SchemaField( + description="Gmail thread with decoded message bodies" + ) + error: str = SchemaField(description="Error message if any") + + def __init__(self): + super().__init__( + id="21a79166-9df7-4b5f-9f36-96f639d86112", + description="Get a full Gmail thread by ID", + categories={BlockCategory.COMMUNICATION}, + input_schema=GmailGetThreadBlock.Input, + output_schema=GmailGetThreadBlock.Output, + disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + test_input={"threadId": "t1", "credentials": TEST_CREDENTIALS_INPUT}, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "thread", + { + "id": "188199feff9dc907", + "messages": [ + { + "id": "188199feff9dc907", + "to": ["nick@example.co"], + "cc": [], + "bcc": [], + "body": "This email does not contain a text body.", + "date": "Thu, 17 Jul 2025 19:22:36 +0100", + "from_": "bent@example.co", + "snippet": "have a funny looking car -- Bently, Community Administrator For AutoGPT", + "subject": "car", + "threadId": "188199feff9dc907", + "labelIds": ["INBOX"], + "attachments": [ + { + "size": 5694, + "filename": "frog.jpg", + "content_type": "image/jpeg", + "attachment_id": "ANGjdJ_f777CvJ37TdHYSPIPPqJ0HVNgze1uM8alw5iiqTqAVXjsmBWxOWXrY3Z4W4rEJHfAcHVx54_TbtcZIVJJEqJfAD5LoUOK9_zKCRwwcTJ5TGgjsXcZNSnOJNazM-m4E6buo2-p0WNcA_hqQvuA36nzS31Olx3m2x7BaG1ILOkBcjlKJl4KCcR0AvnfK0S02k8i-bZVqII7XXrNp21f1BDolxH7tiEhkz3d5p-5Lbro24olgOWQwQk0SCJsTWWBMCVgbxU7oLt1QmPcjANxfpvh69Qfap3htvQxFa9P08NDI2YqQkry9yPxVR7ZBJQWrqO35EWmhNySEiX5pfG8SDRmfP9O_BqxTH35nEXmSOvZH9zb214iM-zfSoPSU1F5Fo71", + } + ], + "sizeEstimate": 14099, + } + ], + "historyId": "645006", + }, + ) + ], + test_mock={ + "_get_thread": lambda *args, **kwargs: { + "id": "188199feff9dc907", + "messages": [ + { + "id": "188199feff9dc907", + "to": ["nick@example.co"], + "cc": [], + "bcc": [], + "body": "This email does not contain a text body.", + "date": "Thu, 17 Jul 2025 19:22:36 +0100", + "from_": "bent@example.co", + "snippet": "have a funny looking car -- Bently, Community Administrator For AutoGPT", + "subject": "car", + "threadId": "188199feff9dc907", + "labelIds": ["INBOX"], + "attachments": [ + { + "size": 5694, + "filename": "frog.jpg", + "content_type": "image/jpeg", + "attachment_id": "ANGjdJ_f777CvJ37TdHYSPIPPqJ0HVNgze1uM8alw5iiqTqAVXjsmBWxOWXrY3Z4W4rEJHfAcHVx54_TbtcZIVJJEqJfAD5LoUOK9_zKCRwwcTJ5TGgjsXcZNSnOJNazM-m4E6buo2-p0WNcA_hqQvuA36nzS31Olx3m2x7BaG1ILOkBcjlKJl4KCcR0AvnfK0S02k8i-bZVqII7XXrNp21f1BDolxH7tiEhkz3d5p-5Lbro24olgOWQwQk0SCJsTWWBMCVgbxU7oLt1QmPcjANxfpvh69Qfap3htvQxFa9P08NDI2YqQkry9yPxVR7ZBJQWrqO35EWmhNySEiX5pfG8SDRmfP9O_BqxTH35nEXmSOvZH9zb214iM-zfSoPSU1F5Fo71", + } + ], + "sizeEstimate": 14099, + } + ], + "historyId": "645006", + } + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = self._build_service(credentials, **kwargs) + thread = await self._get_thread( + service, input_data.threadId, credentials.scopes + ) + yield "thread", thread + + async def _get_thread( + self, service, thread_id: str, scopes: list[str] | None + ) -> Thread: + scopes = [s.lower() for s in (scopes or [])] + format_type = ( + "metadata" + if "https://www.googleapis.com/auth/gmail.metadata" in scopes + else "full" + ) + thread = await asyncio.to_thread( + lambda: service.users() + .threads() + .get(userId="me", id=thread_id, format=format_type) + .execute() + ) + + parsed_messages = [] + for msg in thread.get("messages", []): + headers = { + h["name"].lower(): h["value"] + for h in msg.get("payload", {}).get("headers", []) + } + body = await self._get_email_body(msg, service) + attachments = await self._get_attachments(service, msg) + + # Parse all recipients + to_recipients = [ + addr.strip() for _, addr in getaddresses([headers.get("to", "")]) + ] + cc_recipients = [ + addr.strip() for _, addr in getaddresses([headers.get("cc", "")]) + ] + bcc_recipients = [ + addr.strip() for _, addr in getaddresses([headers.get("bcc", "")]) + ] + + email = Email( + threadId=msg.get("threadId", thread_id), + labelIds=msg.get("labelIds", []), + id=msg.get("id"), + subject=headers.get("subject", "No Subject"), + snippet=msg.get("snippet", ""), + from_=parseaddr(headers.get("from", ""))[1], + to=to_recipients if to_recipients else [], + cc=cc_recipients, + bcc=bcc_recipients, + date=headers.get("date", ""), + body=body, + sizeEstimate=msg.get("sizeEstimate", 0), + attachments=attachments, + ) + parsed_messages.append(email.model_dump()) + + thread["messages"] = parsed_messages + return thread + + +class GmailReplyBlock(GmailBase): + """ + Replies to Gmail threads with intelligent content type detection. + + Features: + - Automatic HTML detection: Replies containing HTML tags are sent as text/html + - No hard-wrap for plain text: Plain text replies preserve natural line flow + - Manual content type override: Use content_type parameter to force specific format + - Reply-all functionality: Option to reply to all original recipients + - Thread preservation: Maintains proper email threading with headers + - Full Unicode/emoji support with UTF-8 encoding + """ + + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + [ + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.readonly", + ] + ) + threadId: str = SchemaField(description="Thread ID to reply in") + parentMessageId: str = SchemaField( + description="ID of the message being replied to" + ) + to: list[str] = SchemaField(description="To recipients", default_factory=list) + cc: list[str] = SchemaField(description="CC recipients", default_factory=list) + bcc: list[str] = SchemaField(description="BCC recipients", default_factory=list) + replyAll: bool = SchemaField( + description="Reply to all original recipients", default=False + ) + subject: str = SchemaField(description="Email subject", default="") + body: str = SchemaField(description="Email body (plain text or HTML)") + content_type: Optional[Literal["auto", "plain", "html"]] = SchemaField( + description="Content type: 'auto' (default - detects HTML), 'plain', or 'html'", + default=None, + advanced=True, + ) + attachments: list[MediaFileType] = SchemaField( + description="Files to attach", default_factory=list, advanced=True + ) + + class Output(BlockSchema): + messageId: str = SchemaField(description="Sent message ID") + threadId: str = SchemaField(description="Thread ID") + message: dict = SchemaField(description="Raw Gmail message object") + email: Email = SchemaField( + description="Parsed email object with decoded body and attachments" + ) + error: str = SchemaField(description="Error message if any") + + def __init__(self): + super().__init__( + id="12bf5a24-9b90-4f40-9090-4e86e6995e60", + description="Reply to Gmail threads with automatic HTML detection and proper text formatting. Plain text replies maintain natural paragraph flow without 78-character line wrapping. HTML content is automatically detected and sent with correct MIME type.", + categories={BlockCategory.COMMUNICATION}, + input_schema=GmailReplyBlock.Input, + output_schema=GmailReplyBlock.Output, + disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + test_input={ + "threadId": "t1", + "parentMessageId": "m1", + "body": "Thanks", + "replyAll": False, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("messageId", "m2"), + ("threadId", "t1"), + ("message", {"id": "m2", "threadId": "t1"}), + ( + "email", + Email( + threadId="t1", + labelIds=[], + id="m2", + subject="", + snippet="", + from_="", + to=[], + cc=[], + bcc=[], + date="", + body="Thanks", + sizeEstimate=0, + attachments=[], + ), + ), + ], + test_mock={ + "_reply": lambda *args, **kwargs: { + "id": "m2", + "threadId": "t1", + } + }, + ) + + async def run( + self, + input_data: Input, + *, + credentials: GoogleCredentials, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + service = self._build_service(credentials, **kwargs) + message = await self._reply( + service, + input_data, + graph_exec_id, + user_id, + ) + yield "messageId", message["id"] + yield "threadId", message.get("threadId", input_data.threadId) + yield "message", message + email = Email( + threadId=message.get("threadId", input_data.threadId), + labelIds=message.get("labelIds", []), + id=message["id"], + subject=input_data.subject or "", + snippet=message.get("snippet", ""), + from_="", # From address would need to be retrieved from the message headers + to=input_data.to if input_data.to else [], + cc=input_data.cc if input_data.cc else [], + bcc=input_data.bcc if input_data.bcc else [], + date="", # Date would need to be retrieved from the message headers + body=input_data.body, + sizeEstimate=message.get("sizeEstimate", 0), + attachments=[], # Attachments info not available from send response + ) + yield "email", email + + async def _reply( + self, service, input_data: Input, graph_exec_id: str, user_id: str + ) -> dict: + parent = await asyncio.to_thread( + lambda: service.users() + .messages() + .get( + userId="me", + id=input_data.parentMessageId, + format="metadata", + metadataHeaders=[ + "Subject", + "References", + "Message-ID", + "From", + "To", + "Cc", + "Reply-To", + ], + ) + .execute() + ) + + headers = { + h["name"].lower(): h["value"] + for h in parent.get("payload", {}).get("headers", []) + } + if not (input_data.to or input_data.cc or input_data.bcc): + if input_data.replyAll: + recipients = [parseaddr(headers.get("from", ""))[1]] + recipients += [ + addr for _, addr in getaddresses([headers.get("to", "")]) + ] + recipients += [ + addr for _, addr in getaddresses([headers.get("cc", "")]) + ] + dedup: list[str] = [] + for r in recipients: + if r and r not in dedup: + dedup.append(r) + input_data.to = dedup + else: + sender = parseaddr(headers.get("reply-to", headers.get("from", "")))[1] + input_data.to = [sender] if sender else [] + subject = input_data.subject or (f"Re: {headers.get('subject', '')}".strip()) + references = headers.get("references", "").split() + if headers.get("message-id"): + references.append(headers["message-id"]) + + msg = MIMEMultipart() + if input_data.to: + msg["To"] = ", ".join(input_data.to) + if input_data.cc: + msg["Cc"] = ", ".join(input_data.cc) + if input_data.bcc: + msg["Bcc"] = ", ".join(input_data.bcc) + msg["Subject"] = subject + if headers.get("message-id"): + msg["In-Reply-To"] = headers["message-id"] + if references: + msg["References"] = " ".join(references) + # Use the new helper function for consistent content type handling + msg.attach(_make_mime_text(input_data.body, input_data.content_type)) + + for attach in input_data.attachments: + local_path = await store_media_file( + user_id=user_id, + graph_exec_id=graph_exec_id, + file=attach, + return_content=False, + ) + abs_path = get_exec_file_path(graph_exec_id, local_path) + part = MIMEBase("application", "octet-stream") + with open(abs_path, "rb") as f: + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", f"attachment; filename={Path(abs_path).name}" + ) + msg.attach(part) + + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8") + return await asyncio.to_thread( + lambda: service.users() + .messages() + .send(userId="me", body={"threadId": input_data.threadId, "raw": raw}) + .execute() + ) + + +class GmailGetProfileBlock(GmailBase): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/gmail.readonly"] + ) + + class Output(BlockSchema): + profile: Profile = SchemaField(description="Gmail user profile information") + error: str = SchemaField(description="Error message if any") + + def __init__(self): + super().__init__( + id="04b0d996-0908-4a4b-89dd-b9697ff253d3", + description="Get the authenticated user's Gmail profile details including email address and message statistics.", + categories={BlockCategory.COMMUNICATION}, + disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + input_schema=GmailGetProfileBlock.Input, + output_schema=GmailGetProfileBlock.Output, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "profile", + { + "emailAddress": "test@example.com", + "messagesTotal": 1000, + "threadsTotal": 500, + "historyId": "12345", + }, + ), + ], + test_mock={ + "_get_profile": lambda *args, **kwargs: { + "emailAddress": "test@example.com", + "messagesTotal": 1000, + "threadsTotal": 500, + "historyId": "12345", + }, + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = self._build_service(credentials, **kwargs) + profile = await self._get_profile(service) + yield "profile", profile + + async def _get_profile(self, service) -> Profile: + result = await asyncio.to_thread( + lambda: service.users().getProfile(userId="me").execute() + ) + return Profile( + emailAddress=result.get("emailAddress", ""), + messagesTotal=result.get("messagesTotal", 0), + threadsTotal=result.get("threadsTotal", 0), + historyId=result.get("historyId", ""), + ) + + +class GmailForwardBlock(GmailBase): + """ + Forwards Gmail messages with intelligent content type detection. + + Features: + - Preserves original message headers and threading + - Automatic HTML detection for forwarded content + - Optional forward message customization + - Full attachment support from original message + - Manual content type override option + """ + + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + [ + "https://www.googleapis.com/auth/gmail.send", + "https://www.googleapis.com/auth/gmail.readonly", + ] + ) + messageId: str = SchemaField(description="ID of the message to forward") + to: list[str] = SchemaField(description="Recipients to forward the message to") + cc: list[str] = SchemaField(description="CC recipients", default_factory=list) + bcc: list[str] = SchemaField(description="BCC recipients", default_factory=list) + subject: str = SchemaField( + description="Optional custom subject (defaults to 'Fwd: [original subject]')", + default="", + ) + forwardMessage: str = SchemaField( + description="Optional message to include before the forwarded content", + default="", + ) + includeAttachments: bool = SchemaField( + description="Include attachments from the original message", + default=True, + ) + content_type: Optional[Literal["auto", "plain", "html"]] = SchemaField( + description="Content type: 'auto' (default - detects HTML), 'plain', or 'html'", + default=None, + advanced=True, + ) + additionalAttachments: list[MediaFileType] = SchemaField( + description="Additional files to attach", + default_factory=list, + advanced=True, + ) + + class Output(BlockSchema): + messageId: str = SchemaField(description="Forwarded message ID") + threadId: str = SchemaField(description="Thread ID") + status: str = SchemaField(description="Forward status") + error: str = SchemaField(description="Error message if any") + + def __init__(self): + super().__init__( + id="64d2301c-b3f5-4174-8ac0-111ca1e1a7c0", + description="Forward Gmail messages to other recipients with automatic HTML detection and proper formatting. Preserves original message threading and attachments.", + categories={BlockCategory.COMMUNICATION}, + input_schema=GmailForwardBlock.Input, + output_schema=GmailForwardBlock.Output, + disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + test_input={ + "messageId": "m1", + "to": ["recipient@example.com"], + "forwardMessage": "FYI - forwarding this to you.", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("messageId", "m2"), + ("threadId", "t1"), + ("status", "forwarded"), + ], + test_mock={ + "_forward_message": lambda *args, **kwargs: { + "id": "m2", + "threadId": "t1", + }, + }, + ) + + async def run( + self, + input_data: Input, + *, + credentials: GoogleCredentials, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + service = self._build_service(credentials, **kwargs) + result = await self._forward_message( + service, + input_data, + graph_exec_id, + user_id, + ) + yield "messageId", result["id"] + yield "threadId", result.get("threadId", "") + yield "status", "forwarded" + + async def _forward_message( + self, service, input_data: Input, graph_exec_id: str, user_id: str + ) -> dict: + if not input_data.to: + raise ValueError("At least one recipient is required for forwarding") + + # Get the original message + original = await asyncio.to_thread( + lambda: service.users() + .messages() + .get(userId="me", id=input_data.messageId, format="full") + .execute() + ) + + headers = { + h["name"].lower(): h["value"] + for h in original.get("payload", {}).get("headers", []) + } + + # Create subject with Fwd: prefix if not already present + original_subject = headers.get("subject", "No Subject") + if input_data.subject: + subject = input_data.subject + elif not original_subject.lower().startswith("fwd:"): + subject = f"Fwd: {original_subject}" + else: + subject = original_subject + + # Build forwarded message body + original_from = headers.get("from", "Unknown") + original_date = headers.get("date", "Unknown") + original_to = headers.get("to", "Unknown") + + # Get the original body + original_body = await self._get_email_body(original, service) + + # Construct the forward header + forward_header = f""" +---------- Forwarded message --------- +From: {original_from} +Date: {original_date} +Subject: {original_subject} +To: {original_to} +""" + + # Combine optional forward message with original content + if input_data.forwardMessage: + body = f"{input_data.forwardMessage}\n\n{forward_header}\n\n{original_body}" + else: + body = f"{forward_header}\n\n{original_body}" + + # Create MIME message + msg = MIMEMultipart() + msg["To"] = ", ".join(input_data.to) + if input_data.cc: + msg["Cc"] = ", ".join(input_data.cc) + if input_data.bcc: + msg["Bcc"] = ", ".join(input_data.bcc) + msg["Subject"] = subject + + # Add body with proper content type + msg.attach(_make_mime_text(body, input_data.content_type)) + + # Include original attachments if requested + if input_data.includeAttachments: + attachments = await self._get_attachments(service, original) + for attachment in attachments: + # Download and attach each original attachment + attachment_data = await self.download_attachment( + service, input_data.messageId, attachment.attachment_id + ) + part = MIMEBase("application", "octet-stream") + part.set_payload(attachment_data) + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", + f"attachment; filename={attachment.filename}", + ) + msg.attach(part) + + # Add any additional attachments + for attach in input_data.additionalAttachments: + local_path = await store_media_file( + user_id=user_id, + graph_exec_id=graph_exec_id, + file=attach, + return_content=False, + ) + abs_path = get_exec_file_path(graph_exec_id, local_path) + part = MIMEBase("application", "octet-stream") + with open(abs_path, "rb") as f: + part.set_payload(f.read()) + encoders.encode_base64(part) + part.add_header( + "Content-Disposition", f"attachment; filename={Path(abs_path).name}" + ) + msg.attach(part) + + # Send the forwarded message + raw = base64.urlsafe_b64encode(msg.as_bytes()).decode("utf-8") + return await asyncio.to_thread( + lambda: service.users() + .messages() + .send(userId="me", body={"raw": raw}) + .execute() + ) diff --git a/autogpt_platform/backend/backend/blocks/google/sheets.py b/autogpt_platform/backend/backend/blocks/google/sheets.py index e7878ff4b606..6e63958c828c 100644 --- a/autogpt_platform/backend/backend/blocks/google/sheets.py +++ b/autogpt_platform/backend/backend/blocks/google/sheets.py @@ -1,8 +1,13 @@ +import asyncio +from enum import Enum +from typing import Any + from google.oauth2.credentials import Credentials from googleapiclient.discovery import build from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField +from backend.util.settings import Settings from ._auth import ( GOOGLE_OAUTH_IS_CONFIGURED, @@ -13,6 +18,192 @@ GoogleCredentialsInput, ) +settings = Settings() +GOOGLE_SHEETS_DISABLED = not GOOGLE_OAUTH_IS_CONFIGURED + + +def parse_a1_notation(a1: str) -> tuple[str | None, str]: + """Split an A1‑notation string into *(sheet_name, cell_range)*. + + Examples + -------- + >>> parse_a1_notation("Sheet1!A1:B2") + ("Sheet1", "A1:B2") + >>> parse_a1_notation("A1:B2") + (None, "A1:B2") + """ + + if "!" in a1: + sheet, cell_range = a1.split("!", 1) + return sheet, cell_range + return None, a1 + + +def extract_spreadsheet_id(spreadsheet_id_or_url: str) -> str: + """Extract spreadsheet ID from either a direct ID or a Google Sheets URL. + + Examples + -------- + >>> extract_spreadsheet_id("1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms") + "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms" + >>> extract_spreadsheet_id("https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit") + "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms" + """ + if "/spreadsheets/d/" in spreadsheet_id_or_url: + # Extract ID from URL: https://docs.google.com/spreadsheets/d/{ID}/edit... + parts = spreadsheet_id_or_url.split("/d/")[1].split("/")[0] + return parts + return spreadsheet_id_or_url + + +def format_sheet_name(sheet_name: str) -> str: + """Format sheet name for Google Sheets API, adding quotes if needed. + + Examples + -------- + >>> format_sheet_name("Sheet1") + "Sheet1" + >>> format_sheet_name("Non-matching Leads") + "'Non-matching Leads'" + """ + # If sheet name contains spaces, special characters, or starts with a digit, wrap in quotes + if ( + " " in sheet_name + or any(char in sheet_name for char in "!@#$%^&*()+-=[]{}|;:,.<>?") + or (sheet_name and sheet_name[0].isdigit()) + ): + return f"'{sheet_name}'" + return sheet_name + + +def _first_sheet_meta(service, spreadsheet_id: str) -> tuple[str, int]: + """Return *(title, sheetId)* for the first sheet in *spreadsheet_id*.""" + + meta = ( + service.spreadsheets() + .get(spreadsheetId=spreadsheet_id, includeGridData=False) + .execute() + ) + first = meta["sheets"][0]["properties"] + return first["title"], first["sheetId"] + + +def get_all_sheet_names(service, spreadsheet_id: str) -> list[str]: + """Get all sheet names in the spreadsheet.""" + meta = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute() + return [ + sheet.get("properties", {}).get("title", "") for sheet in meta.get("sheets", []) + ] + + +def resolve_sheet_name(service, spreadsheet_id: str, sheet_name: str | None) -> str: + """Resolve *sheet_name*, falling back to the workbook's first sheet if empty. + + Validates that the sheet exists in the spreadsheet and provides helpful error info. + """ + if sheet_name: + # Validate that the sheet exists + all_sheets = get_all_sheet_names(service, spreadsheet_id) + if sheet_name not in all_sheets: + raise ValueError( + f'Sheet "{sheet_name}" not found in spreadsheet. ' + f"Available sheets: {all_sheets}" + ) + return sheet_name + title, _ = _first_sheet_meta(service, spreadsheet_id) + return title + + +def sheet_id_by_name(service, spreadsheet_id: str, sheet_name: str) -> int | None: + """Return the *sheetId* for *sheet_name* (or `None` if not found).""" + + meta = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute() + for sh in meta.get("sheets", []): + if sh.get("properties", {}).get("title") == sheet_name: + return sh["properties"]["sheetId"] + return None + + +def _convert_dicts_to_rows( + data: list[dict[str, Any]], headers: list[str] +) -> list[list[str]]: + """Convert list of dictionaries to list of rows using the specified header order. + + Args: + data: List of dictionaries to convert + headers: List of column headers to use for ordering + + Returns: + List of rows where each row is a list of string values in header order + """ + if not data: + return [] + + if not headers: + raise ValueError("Headers are required when using list[dict] format") + + rows = [] + for item in data: + row = [] + for header in headers: + value = item.get(header, "") + row.append(str(value) if value is not None else "") + rows.append(row) + + return rows + + +def _build_sheets_service(credentials: GoogleCredentials): + settings = Settings() + creds = Credentials( + token=( + credentials.access_token.get_secret_value() + if credentials.access_token + else None + ), + refresh_token=( + credentials.refresh_token.get_secret_value() + if credentials.refresh_token + else None + ), + token_uri="https://oauth2.googleapis.com/token", + client_id=settings.secrets.google_client_id, + client_secret=settings.secrets.google_client_secret, + scopes=credentials.scopes, + ) + return build("sheets", "v4", credentials=creds) + + +class SheetOperation(str, Enum): + CREATE = "create" + DELETE = "delete" + COPY = "copy" + + +class ValueInputOption(str, Enum): + RAW = "RAW" + USER_ENTERED = "USER_ENTERED" + + +class InsertDataOption(str, Enum): + OVERWRITE = "OVERWRITE" + INSERT_ROWS = "INSERT_ROWS" + + +class BatchOperationType(str, Enum): + UPDATE = "update" + CLEAR = "clear" + + +class BatchOperation(BlockSchema): + type: BatchOperationType = SchemaField( + description="The type of operation to perform" + ) + range: str = SchemaField(description="The A1 notation range for the operation") + values: list[list[str]] = SchemaField( + description="Values to update (only for UPDATE)", default=[] + ) + class GoogleSheetsReadBlock(Block): class Input(BlockSchema): @@ -20,7 +211,8 @@ class Input(BlockSchema): ["https://www.googleapis.com/auth/spreadsheets.readonly"] ) spreadsheet_id: str = SchemaField( - description="The ID of the spreadsheet to read from", + description="The ID or URL of the spreadsheet to read from", + title="Spreadsheet ID or URL", ) range: str = SchemaField( description="The A1 notation of the range to read", @@ -41,7 +233,7 @@ def __init__(self): categories={BlockCategory.DATA}, input_schema=GoogleSheetsReadBlock.Input, output_schema=GoogleSheetsReadBlock.Output, - disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + disabled=GOOGLE_SHEETS_DISABLED, test_input={ "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", "range": "Sheet1!A1:B2", @@ -65,33 +257,16 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = self._build_service(credentials, **kwargs) - data = self._read_sheet(service, input_data.spreadsheet_id, input_data.range) + service = _build_sheets_service(credentials) + spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) + data = await asyncio.to_thread( + self._read_sheet, service, spreadsheet_id, input_data.range + ) yield "result", data - @staticmethod - def _build_service(credentials: GoogleCredentials, **kwargs): - creds = Credentials( - token=( - credentials.access_token.get_secret_value() - if credentials.access_token - else None - ), - refresh_token=( - credentials.refresh_token.get_secret_value() - if credentials.refresh_token - else None - ), - token_uri="https://oauth2.googleapis.com/token", - client_id=kwargs.get("client_id"), - client_secret=kwargs.get("client_secret"), - scopes=credentials.scopes, - ) - return build("sheets", "v4", credentials=creds) - def _read_sheet(self, service, spreadsheet_id: str, range: str) -> list[list[str]]: sheet = service.spreadsheets() result = sheet.values().get(spreadsheetId=spreadsheet_id, range=range).execute() @@ -104,7 +279,8 @@ class Input(BlockSchema): ["https://www.googleapis.com/auth/spreadsheets"] ) spreadsheet_id: str = SchemaField( - description="The ID of the spreadsheet to write to", + description="The ID or URL of the spreadsheet to write to", + title="Spreadsheet ID or URL", ) range: str = SchemaField( description="The A1 notation of the range to write", @@ -128,7 +304,7 @@ def __init__(self): categories={BlockCategory.DATA}, input_schema=GoogleSheetsWriteBlock.Input, output_schema=GoogleSheetsWriteBlock.Output, - disabled=not GOOGLE_OAUTH_IS_CONFIGURED, + disabled=GOOGLE_SHEETS_DISABLED, test_input={ "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", "range": "Sheet1!A1:B2", @@ -154,13 +330,15 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: GoogleCredentials, **kwargs ) -> BlockOutput: - service = GoogleSheetsReadBlock._build_service(credentials, **kwargs) - result = self._write_sheet( + service = _build_sheets_service(credentials) + spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) + result = await asyncio.to_thread( + self._write_sheet, service, - input_data.spreadsheet_id, + spreadsheet_id, input_data.range, input_data.values, ) @@ -182,3 +360,1136 @@ def _write_sheet( .execute() ) return result + + +class GoogleSheetsAppendBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/spreadsheets"] + ) + spreadsheet_id: str = SchemaField( + description="Spreadsheet ID or URL", + title="Spreadsheet ID or URL", + ) + sheet_name: str = SchemaField( + description="Optional sheet to append to (defaults to first sheet)", + default="", + ) + values: list[list[str]] = SchemaField( + description="Rows to append as list of rows (list[list[str]])", + default=[], + ) + dict_values: list[dict[str, Any]] = SchemaField( + description="Rows to append as list of dictionaries (list[dict])", + default=[], + ) + headers: list[str] = SchemaField( + description="Column headers to use for ordering dict values (required when dict_values is provided)", + default=[], + ) + range: str = SchemaField( + description="Range to append to (e.g. 'A:A' for column A only, 'A:C' for columns A-C, or leave empty for unlimited columns). When empty, data will span as many columns as needed.", + default="", + advanced=True, + ) + value_input_option: ValueInputOption = SchemaField( + description="How input data should be interpreted", + default=ValueInputOption.USER_ENTERED, + advanced=True, + ) + insert_data_option: InsertDataOption = SchemaField( + description="How new data should be inserted", + default=InsertDataOption.INSERT_ROWS, + advanced=True, + ) + + class Output(BlockSchema): + result: dict = SchemaField(description="Append API response") + error: str = SchemaField(description="Error message, if any") + + def __init__(self): + super().__init__( + id="531d50c0-d6b9-4cf9-a013-7bf783d313c7", + description="Append data to a Google Sheet. Use 'values' for list of rows (list[list[str]]) or 'dict_values' with 'headers' for list of dictionaries (list[dict]). Data is added to the next empty row without overwriting existing content. Leave range empty for unlimited columns, or specify range like 'A:A' to constrain to specific columns.", + categories={BlockCategory.DATA}, + input_schema=GoogleSheetsAppendBlock.Input, + output_schema=GoogleSheetsAppendBlock.Output, + disabled=GOOGLE_SHEETS_DISABLED, + test_input={ + "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "values": [["Charlie", "95"]], + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("result", {"updatedCells": 2, "updatedColumns": 2, "updatedRows": 1}), + ], + test_mock={ + "_append_sheet": lambda *args, **kwargs: { + "updatedCells": 2, + "updatedColumns": 2, + "updatedRows": 1, + }, + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = _build_sheets_service(credentials) + spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) + # Determine which values to use and convert if needed + processed_values: list[list[str]] + + # Validate that only one format is provided + if input_data.values and input_data.dict_values: + raise ValueError("Provide either 'values' or 'dict_values', not both") + + if input_data.dict_values: + if not input_data.headers: + raise ValueError("Headers are required when using dict_values") + processed_values = _convert_dicts_to_rows( + input_data.dict_values, input_data.headers + ) + elif input_data.values: + processed_values = input_data.values + else: + raise ValueError("Either 'values' or 'dict_values' must be provided") + + result = await asyncio.to_thread( + self._append_sheet, + service, + spreadsheet_id, + input_data.sheet_name, + processed_values, + input_data.range, + input_data.value_input_option, + input_data.insert_data_option, + ) + yield "result", result + + def _append_sheet( + self, + service, + spreadsheet_id: str, + sheet_name: str, + values: list[list[str]], + range: str, + value_input_option: ValueInputOption, + insert_data_option: InsertDataOption, + ) -> dict: + target_sheet = resolve_sheet_name(service, spreadsheet_id, sheet_name) + formatted_sheet = format_sheet_name(target_sheet) + # If no range specified, use A1 to let Google Sheets find the next empty row with unlimited columns + # If range specified, use it to constrain columns (e.g., A:A for column A only) + if range: + append_range = f"{formatted_sheet}!{range}" + else: + # Use A1 as starting point for unlimited columns - Google Sheets will find next empty row + append_range = f"{formatted_sheet}!A1" + body = {"values": values} + return ( + service.spreadsheets() + .values() + .append( + spreadsheetId=spreadsheet_id, + range=append_range, + valueInputOption=value_input_option.value, + insertDataOption=insert_data_option.value, + body=body, + ) + .execute() + ) + + +class GoogleSheetsClearBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/spreadsheets"] + ) + spreadsheet_id: str = SchemaField( + description="The ID or URL of the spreadsheet to clear", + title="Spreadsheet ID or URL", + ) + range: str = SchemaField( + description="The A1 notation of the range to clear", + ) + + class Output(BlockSchema): + result: dict = SchemaField( + description="The result of the clear operation", + ) + error: str = SchemaField( + description="Error message if any", + ) + + def __init__(self): + super().__init__( + id="84938266-0fc7-46e5-9369-adb0f6ae8015", + description="This block clears data from a specified range in a Google Sheets spreadsheet.", + categories={BlockCategory.DATA}, + input_schema=GoogleSheetsClearBlock.Input, + output_schema=GoogleSheetsClearBlock.Output, + disabled=GOOGLE_SHEETS_DISABLED, + test_input={ + "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "range": "Sheet1!A1:B2", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("result", {"clearedRange": "Sheet1!A1:B2"}), + ], + test_mock={ + "_clear_range": lambda *args, **kwargs: { + "clearedRange": "Sheet1!A1:B2" + }, + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = _build_sheets_service(credentials) + spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) + result = await asyncio.to_thread( + self._clear_range, + service, + spreadsheet_id, + input_data.range, + ) + yield "result", result + + def _clear_range(self, service, spreadsheet_id: str, range: str) -> dict: + result = ( + service.spreadsheets() + .values() + .clear(spreadsheetId=spreadsheet_id, range=range) + .execute() + ) + return result + + +class GoogleSheetsMetadataBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/spreadsheets.readonly"] + ) + spreadsheet_id: str = SchemaField( + description="The ID or URL of the spreadsheet to get metadata for", + title="Spreadsheet ID or URL", + ) + + class Output(BlockSchema): + result: dict = SchemaField( + description="The metadata of the spreadsheet including sheets info", + ) + error: str = SchemaField( + description="Error message if any", + ) + + def __init__(self): + super().__init__( + id="6a0be6ee-7a0d-4c92-819b-500630846ad0", + description="This block retrieves metadata about a Google Sheets spreadsheet including sheet names and properties.", + categories={BlockCategory.DATA}, + input_schema=GoogleSheetsMetadataBlock.Input, + output_schema=GoogleSheetsMetadataBlock.Output, + disabled=GOOGLE_SHEETS_DISABLED, + test_input={ + "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "result", + { + "title": "Test Spreadsheet", + "sheets": [{"title": "Sheet1", "sheetId": 0}], + }, + ), + ], + test_mock={ + "_get_metadata": lambda *args, **kwargs: { + "title": "Test Spreadsheet", + "sheets": [{"title": "Sheet1", "sheetId": 0}], + }, + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = _build_sheets_service(credentials) + spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) + result = await asyncio.to_thread( + self._get_metadata, + service, + spreadsheet_id, + ) + yield "result", result + + def _get_metadata(self, service, spreadsheet_id: str) -> dict: + result = ( + service.spreadsheets() + .get(spreadsheetId=spreadsheet_id, includeGridData=False) + .execute() + ) + return { + "title": result.get("properties", {}).get("title"), + "sheets": [ + { + "title": sheet.get("properties", {}).get("title"), + "sheetId": sheet.get("properties", {}).get("sheetId"), + "gridProperties": sheet.get("properties", {}).get( + "gridProperties", {} + ), + } + for sheet in result.get("sheets", []) + ], + } + + +class GoogleSheetsManageSheetBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/spreadsheets"] + ) + spreadsheet_id: str = SchemaField( + description="Spreadsheet ID or URL", + title="Spreadsheet ID or URL", + ) + operation: SheetOperation = SchemaField(description="Operation to perform") + sheet_name: str = SchemaField( + description="Target sheet name (defaults to first sheet for delete)", + default="", + ) + source_sheet_id: int = SchemaField( + description="Source sheet ID for copy", default=0 + ) + destination_sheet_name: str = SchemaField( + description="New sheet name for copy", default="" + ) + + class Output(BlockSchema): + result: dict = SchemaField(description="Operation result") + error: str = SchemaField(description="Error message, if any") + + def __init__(self): + super().__init__( + id="7940189d-b137-4ef1-aa18-3dd9a5bde9f3", + description="Create, delete, or copy sheets (sheet optional)", + categories={BlockCategory.DATA}, + input_schema=GoogleSheetsManageSheetBlock.Input, + output_schema=GoogleSheetsManageSheetBlock.Output, + disabled=GOOGLE_SHEETS_DISABLED, + test_input={ + "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "operation": SheetOperation.CREATE, + "sheet_name": "NewSheet", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("result", {"success": True, "sheetId": 123})], + test_mock={ + "_manage_sheet": lambda *args, **kwargs: { + "success": True, + "sheetId": 123, + } + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = _build_sheets_service(credentials) + spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) + result = await asyncio.to_thread( + self._manage_sheet, + service, + spreadsheet_id, + input_data.operation, + input_data.sheet_name, + input_data.source_sheet_id, + input_data.destination_sheet_name, + ) + yield "result", result + + def _manage_sheet( + self, + service, + spreadsheet_id: str, + operation: SheetOperation, + sheet_name: str, + source_sheet_id: int, + destination_sheet_name: str, + ) -> dict: + requests = [] + + # Ensure a target sheet name when needed + target_name = resolve_sheet_name(service, spreadsheet_id, sheet_name) + + if operation == SheetOperation.CREATE: + requests.append({"addSheet": {"properties": {"title": target_name}}}) + elif operation == SheetOperation.DELETE: + sid = sheet_id_by_name(service, spreadsheet_id, target_name) + if sid is None: + return {"error": f"Sheet '{target_name}' not found"} + requests.append({"deleteSheet": {"sheetId": sid}}) + elif operation == SheetOperation.COPY: + requests.append( + { + "duplicateSheet": { + "sourceSheetId": source_sheet_id, + "newSheetName": destination_sheet_name + or f"Copy of {source_sheet_id}", + } + } + ) + else: + return {"error": f"Unknown operation: {operation}"} + + body = {"requests": requests} + result = ( + service.spreadsheets() + .batchUpdate(spreadsheetId=spreadsheet_id, body=body) + .execute() + ) + return {"success": True, "result": result} + + +class GoogleSheetsBatchOperationsBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/spreadsheets"] + ) + spreadsheet_id: str = SchemaField( + description="The ID or URL of the spreadsheet to perform batch operations on", + title="Spreadsheet ID or URL", + ) + operations: list[BatchOperation] = SchemaField( + description="List of operations to perform", + ) + + class Output(BlockSchema): + result: dict = SchemaField( + description="The result of the batch operations", + ) + error: str = SchemaField( + description="Error message if any", + ) + + def __init__(self): + super().__init__( + id="a4078584-6fe5-46e0-997e-d5126cdd112a", + description="This block performs multiple operations on a Google Sheets spreadsheet in a single batch request.", + categories={BlockCategory.DATA}, + input_schema=GoogleSheetsBatchOperationsBlock.Input, + output_schema=GoogleSheetsBatchOperationsBlock.Output, + disabled=GOOGLE_SHEETS_DISABLED, + test_input={ + "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "operations": [ + { + "type": BatchOperationType.UPDATE, + "range": "A1:B1", + "values": [["Header1", "Header2"]], + }, + { + "type": BatchOperationType.UPDATE, + "range": "A2:B2", + "values": [["Data1", "Data2"]], + }, + ], + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("result", {"totalUpdatedCells": 4, "replies": []}), + ], + test_mock={ + "_batch_operations": lambda *args, **kwargs: { + "totalUpdatedCells": 4, + "replies": [], + }, + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = _build_sheets_service(credentials) + spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) + result = await asyncio.to_thread( + self._batch_operations, + service, + spreadsheet_id, + input_data.operations, + ) + yield "result", result + + def _batch_operations( + self, service, spreadsheet_id: str, operations: list[BatchOperation] + ) -> dict: + update_data = [] + clear_ranges = [] + + for op in operations: + if op.type == BatchOperationType.UPDATE: + update_data.append( + { + "range": op.range, + "values": op.values, + } + ) + elif op.type == BatchOperationType.CLEAR: + clear_ranges.append(op.range) + + results = {} + + # Perform updates if any + if update_data: + update_body = { + "valueInputOption": "USER_ENTERED", + "data": update_data, + } + update_result = ( + service.spreadsheets() + .values() + .batchUpdate(spreadsheetId=spreadsheet_id, body=update_body) + .execute() + ) + results["updateResult"] = update_result + + # Perform clears if any + if clear_ranges: + clear_body = {"ranges": clear_ranges} + clear_result = ( + service.spreadsheets() + .values() + .batchClear(spreadsheetId=spreadsheet_id, body=clear_body) + .execute() + ) + results["clearResult"] = clear_result + + return results + + +class GoogleSheetsFindReplaceBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/spreadsheets"] + ) + spreadsheet_id: str = SchemaField( + description="The ID or URL of the spreadsheet to perform find/replace on", + title="Spreadsheet ID or URL", + ) + find_text: str = SchemaField( + description="The text to find", + ) + replace_text: str = SchemaField( + description="The text to replace with", + ) + sheet_id: int = SchemaField( + description="The ID of the specific sheet to search (optional, searches all sheets if not provided)", + default=-1, + ) + match_case: bool = SchemaField( + description="Whether to match case", + default=False, + ) + match_entire_cell: bool = SchemaField( + description="Whether to match entire cell", + default=False, + ) + + class Output(BlockSchema): + result: dict = SchemaField( + description="The result of the find/replace operation including number of replacements", + ) + error: str = SchemaField( + description="Error message if any", + ) + + def __init__(self): + super().__init__( + id="accca760-8174-4656-b55e-5f0e82fee986", + description="This block finds and replaces text in a Google Sheets spreadsheet.", + categories={BlockCategory.DATA}, + input_schema=GoogleSheetsFindReplaceBlock.Input, + output_schema=GoogleSheetsFindReplaceBlock.Output, + disabled=GOOGLE_SHEETS_DISABLED, + test_input={ + "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "find_text": "old_value", + "replace_text": "new_value", + "match_case": False, + "match_entire_cell": False, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("result", {"occurrencesChanged": 5}), + ], + test_mock={ + "_find_replace": lambda *args, **kwargs: {"occurrencesChanged": 5}, + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = _build_sheets_service(credentials) + spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) + result = await asyncio.to_thread( + self._find_replace, + service, + spreadsheet_id, + input_data.find_text, + input_data.replace_text, + input_data.sheet_id, + input_data.match_case, + input_data.match_entire_cell, + ) + yield "result", result + + def _find_replace( + self, + service, + spreadsheet_id: str, + find_text: str, + replace_text: str, + sheet_id: int, + match_case: bool, + match_entire_cell: bool, + ) -> dict: + find_replace_request = { + "find": find_text, + "replacement": replace_text, + "matchCase": match_case, + "matchEntireCell": match_entire_cell, + } + + if sheet_id >= 0: + find_replace_request["sheetId"] = sheet_id + + requests = [{"findReplace": find_replace_request}] + body = {"requests": requests} + + result = ( + service.spreadsheets() + .batchUpdate(spreadsheetId=spreadsheet_id, body=body) + .execute() + ) + + return result + + +class GoogleSheetsFindBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/spreadsheets.readonly"] + ) + spreadsheet_id: str = SchemaField( + description="The ID or URL of the spreadsheet to search in", + title="Spreadsheet ID or URL", + ) + find_text: str = SchemaField( + description="The text to find", + ) + sheet_id: int = SchemaField( + description="The ID of the specific sheet to search (optional, searches all sheets if not provided)", + default=-1, + ) + match_case: bool = SchemaField( + description="Whether to match case", + default=False, + ) + match_entire_cell: bool = SchemaField( + description="Whether to match entire cell", + default=False, + ) + find_all: bool = SchemaField( + description="Whether to find all occurrences (true) or just the first one (false)", + default=True, + ) + range: str = SchemaField( + description="The A1 notation range to search in (optional, searches entire sheet if not provided)", + default="", + advanced=True, + ) + + class Output(BlockSchema): + result: dict = SchemaField( + description="The result of the find operation including locations and count", + ) + locations: list[dict] = SchemaField( + description="List of cell locations where the text was found", + ) + count: int = SchemaField( + description="Number of occurrences found", + ) + error: str = SchemaField( + description="Error message if any", + ) + + def __init__(self): + super().__init__( + id="0f4ecc72-b958-47b2-b65e-76d6d26b9b27", + description="Find text in a Google Sheets spreadsheet. Returns locations and count of occurrences. Can find all occurrences or just the first one.", + categories={BlockCategory.DATA}, + input_schema=GoogleSheetsFindBlock.Input, + output_schema=GoogleSheetsFindBlock.Output, + disabled=GOOGLE_SHEETS_DISABLED, + test_input={ + "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "find_text": "search_value", + "match_case": False, + "match_entire_cell": False, + "find_all": True, + "range": "Sheet1!A1:C10", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("count", 3), + ( + "locations", + [ + {"sheet": "Sheet1", "row": 2, "column": 1, "address": "A2"}, + {"sheet": "Sheet1", "row": 5, "column": 3, "address": "C5"}, + {"sheet": "Sheet2", "row": 1, "column": 2, "address": "B1"}, + ], + ), + ("result", {"success": True}), + ], + test_mock={ + "_find_text": lambda *args, **kwargs: { + "locations": [ + {"sheet": "Sheet1", "row": 2, "column": 1, "address": "A2"}, + {"sheet": "Sheet1", "row": 5, "column": 3, "address": "C5"}, + {"sheet": "Sheet2", "row": 1, "column": 2, "address": "B1"}, + ], + "count": 3, + }, + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = _build_sheets_service(credentials) + spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) + result = await asyncio.to_thread( + self._find_text, + service, + spreadsheet_id, + input_data.find_text, + input_data.sheet_id, + input_data.match_case, + input_data.match_entire_cell, + input_data.find_all, + input_data.range, + ) + yield "count", result["count"] + yield "locations", result["locations"] + yield "result", {"success": True} + + def _find_text( + self, + service, + spreadsheet_id: str, + find_text: str, + sheet_id: int, + match_case: bool, + match_entire_cell: bool, + find_all: bool, + range: str, + ) -> dict: + # Unfortunately, Google Sheets API doesn't have a dedicated "find-only" operation + # that returns cell locations. The findReplace operation only returns a count. + # So we need to search through the values manually to get location details. + + locations = [] + search_range = range if range else None + + if not search_range: + # If no range specified, search entire spreadsheet + meta = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute() + sheets = meta.get("sheets", []) + + # Filter to specific sheet if provided + if sheet_id >= 0: + sheets = [ + s + for s in sheets + if s.get("properties", {}).get("sheetId") == sheet_id + ] + + # Search each sheet + for sheet in sheets: + sheet_name = sheet.get("properties", {}).get("title", "") + sheet_range = f"'{sheet_name}'" + self._search_range( + service, + spreadsheet_id, + sheet_range, + sheet_name, + find_text, + match_case, + match_entire_cell, + find_all, + locations, + ) + if not find_all and locations: + break + else: + # Search specific range + sheet_name, cell_range = parse_a1_notation(search_range) + if not sheet_name: + # Get first sheet name if not specified + meta = ( + service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute() + ) + sheet_name = ( + meta.get("sheets", [{}])[0] + .get("properties", {}) + .get("title", "Sheet1") + ) + search_range = f"'{sheet_name}'!{search_range}" + + self._search_range( + service, + spreadsheet_id, + search_range, + sheet_name, + find_text, + match_case, + match_entire_cell, + find_all, + locations, + ) + + return {"locations": locations, "count": len(locations)} + + def _search_range( + self, + service, + spreadsheet_id: str, + range_name: str, + sheet_name: str, + find_text: str, + match_case: bool, + match_entire_cell: bool, + find_all: bool, + locations: list, + ): + """Search within a specific range and add results to locations list.""" + values_result = ( + service.spreadsheets() + .values() + .get(spreadsheetId=spreadsheet_id, range=range_name) + .execute() + ) + values = values_result.get("values", []) + + # Parse range to get starting position + _, cell_range = parse_a1_notation(range_name) + start_col = 0 + start_row = 0 + + if cell_range and ":" in cell_range: + start_cell = cell_range.split(":")[0] + # Parse A1 notation (e.g., "B3" -> col=1, row=2) + col_part = "" + row_part = "" + for char in start_cell: + if char.isalpha(): + col_part += char + elif char.isdigit(): + row_part += char + + if col_part: + start_col = ord(col_part.upper()) - ord("A") + if row_part: + start_row = int(row_part) - 1 + + # Search through values + for row_idx, row in enumerate(values): + for col_idx, cell_value in enumerate(row): + if cell_value is None: + continue + + cell_str = str(cell_value) + + # Apply search criteria + search_text = find_text if match_case else find_text.lower() + cell_text = cell_str if match_case else cell_str.lower() + + found = False + if match_entire_cell: + found = cell_text == search_text + else: + found = search_text in cell_text + + if found: + # Calculate actual spreadsheet position + actual_row = start_row + row_idx + 1 + actual_col = start_col + col_idx + 1 + col_letter = chr(ord("A") + start_col + col_idx) + address = f"{col_letter}{actual_row}" + + location = { + "sheet": sheet_name, + "row": actual_row, + "column": actual_col, + "address": address, + "value": cell_str, + } + locations.append(location) + + # Stop after first match if find_all is False + if not find_all: + return + + +class GoogleSheetsFormatBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/spreadsheets"] + ) + spreadsheet_id: str = SchemaField( + description="Spreadsheet ID or URL", + title="Spreadsheet ID or URL", + ) + range: str = SchemaField(description="A1 notation – sheet optional") + background_color: dict = SchemaField(default={}) + text_color: dict = SchemaField(default={}) + bold: bool = SchemaField(default=False) + italic: bool = SchemaField(default=False) + font_size: int = SchemaField(default=10) + + class Output(BlockSchema): + result: dict = SchemaField(description="API response or success flag") + error: str = SchemaField(description="Error message, if any") + + def __init__(self): + super().__init__( + id="270f2384-8089-4b5b-b2e3-fe2ea3d87c02", + description="Format a range in a Google Sheet (sheet optional)", + categories={BlockCategory.DATA}, + input_schema=GoogleSheetsFormatBlock.Input, + output_schema=GoogleSheetsFormatBlock.Output, + disabled=GOOGLE_SHEETS_DISABLED, + test_input={ + "spreadsheet_id": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "range": "A1:B2", + "background_color": {"red": 1.0, "green": 0.9, "blue": 0.9}, + "bold": True, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("result", {"success": True})], + test_mock={"_format_cells": lambda *args, **kwargs: {"success": True}}, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = _build_sheets_service(credentials) + spreadsheet_id = extract_spreadsheet_id(input_data.spreadsheet_id) + result = await asyncio.to_thread( + self._format_cells, + service, + spreadsheet_id, + input_data.range, + input_data.background_color, + input_data.text_color, + input_data.bold, + input_data.italic, + input_data.font_size, + ) + if "error" in result: + yield "error", result["error"] + else: + yield "result", result + + def _format_cells( + self, + service, + spreadsheet_id: str, + a1_range: str, + background_color: dict, + text_color: dict, + bold: bool, + italic: bool, + font_size: int, + ) -> dict: + sheet_name, cell_range = parse_a1_notation(a1_range) + sheet_name = resolve_sheet_name(service, spreadsheet_id, sheet_name) + + sheet_id = sheet_id_by_name(service, spreadsheet_id, sheet_name) + if sheet_id is None: + return {"error": f"Sheet '{sheet_name}' not found"} + + try: + start_cell, end_cell = cell_range.split(":") + start_col = ord(start_cell[0].upper()) - ord("A") + start_row = int(start_cell[1:]) - 1 + end_col = ord(end_cell[0].upper()) - ord("A") + 1 + end_row = int(end_cell[1:]) + except (ValueError, IndexError): + return {"error": f"Invalid range format: {a1_range}"} + + cell_format: dict = {"userEnteredFormat": {}} + if background_color: + cell_format["userEnteredFormat"]["backgroundColor"] = background_color + + text_format: dict = {} + if text_color: + text_format["foregroundColor"] = text_color + if bold: + text_format["bold"] = True + if italic: + text_format["italic"] = True + if font_size != 10: + text_format["fontSize"] = font_size + if text_format: + cell_format["userEnteredFormat"]["textFormat"] = text_format + + body = { + "requests": [ + { + "repeatCell": { + "range": { + "sheetId": sheet_id, + "startRowIndex": start_row, + "endRowIndex": end_row, + "startColumnIndex": start_col, + "endColumnIndex": end_col, + }, + "cell": cell_format, + "fields": "userEnteredFormat(backgroundColor,textFormat)", + } + } + ] + } + + service.spreadsheets().batchUpdate( + spreadsheetId=spreadsheet_id, body=body + ).execute() + return {"success": True} + + +class GoogleSheetsCreateSpreadsheetBlock(Block): + class Input(BlockSchema): + credentials: GoogleCredentialsInput = GoogleCredentialsField( + ["https://www.googleapis.com/auth/spreadsheets"] + ) + title: str = SchemaField( + description="The title of the new spreadsheet", + ) + sheet_names: list[str] = SchemaField( + description="List of sheet names to create (optional, defaults to single 'Sheet1')", + default=["Sheet1"], + ) + + class Output(BlockSchema): + result: dict = SchemaField( + description="The result containing spreadsheet ID and URL", + ) + spreadsheet_id: str = SchemaField( + description="The ID of the created spreadsheet", + ) + spreadsheet_url: str = SchemaField( + description="The URL of the created spreadsheet", + ) + error: str = SchemaField( + description="Error message if any", + ) + + def __init__(self): + super().__init__( + id="c8d4c0d3-c76e-4c2a-8c66-4119817ea3d1", + description="This block creates a new Google Sheets spreadsheet with specified sheets.", + categories={BlockCategory.DATA}, + input_schema=GoogleSheetsCreateSpreadsheetBlock.Input, + output_schema=GoogleSheetsCreateSpreadsheetBlock.Output, + disabled=GOOGLE_SHEETS_DISABLED, + test_input={ + "title": "Test Spreadsheet", + "sheet_names": ["Sheet1", "Data", "Summary"], + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("spreadsheet_id", "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms"), + ( + "spreadsheet_url", + "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + ), + ("result", {"success": True}), + ], + test_mock={ + "_create_spreadsheet": lambda *args, **kwargs: { + "spreadsheetId": "1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms", + "spreadsheetUrl": "https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms/edit", + }, + }, + ) + + async def run( + self, input_data: Input, *, credentials: GoogleCredentials, **kwargs + ) -> BlockOutput: + service = _build_sheets_service(credentials) + result = await asyncio.to_thread( + self._create_spreadsheet, + service, + input_data.title, + input_data.sheet_names, + ) + + if "error" in result: + yield "error", result["error"] + else: + yield "spreadsheet_id", result["spreadsheetId"] + yield "spreadsheet_url", result["spreadsheetUrl"] + yield "result", {"success": True} + + def _create_spreadsheet(self, service, title: str, sheet_names: list[str]) -> dict: + try: + # Create the initial spreadsheet + spreadsheet_body = { + "properties": {"title": title}, + "sheets": [ + { + "properties": { + "title": sheet_names[0] if sheet_names else "Sheet1" + } + } + ], + } + + result = service.spreadsheets().create(body=spreadsheet_body).execute() + spreadsheet_id = result["spreadsheetId"] + spreadsheet_url = result["spreadsheetUrl"] + + # Add additional sheets if requested + if len(sheet_names) > 1: + requests = [] + for sheet_name in sheet_names[1:]: + requests.append({"addSheet": {"properties": {"title": sheet_name}}}) + + if requests: + batch_body = {"requests": requests} + service.spreadsheets().batchUpdate( + spreadsheetId=spreadsheet_id, body=batch_body + ).execute() + + return { + "spreadsheetId": spreadsheet_id, + "spreadsheetUrl": spreadsheet_url, + } + except Exception as e: + return {"error": str(e)} diff --git a/autogpt_platform/backend/backend/blocks/google_maps.py b/autogpt_platform/backend/backend/blocks/google_maps.py index 9e7f79353123..01e81c69c940 100644 --- a/autogpt_platform/backend/backend/blocks/google_maps.py +++ b/autogpt_platform/backend/backend/blocks/google_maps.py @@ -103,7 +103,7 @@ def __init__(self): test_credentials=TEST_CREDENTIALS, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: places = self.search_places( diff --git a/autogpt_platform/backend/backend/blocks/helpers/http.py b/autogpt_platform/backend/backend/blocks/helpers/http.py index 33579ba0d9c9..f68b9f5a8b0c 100644 --- a/autogpt_platform/backend/backend/blocks/helpers/http.py +++ b/autogpt_platform/backend/backend/blocks/helpers/http.py @@ -1,14 +1,17 @@ from typing import Any, Optional -from backend.util.request import requests +from backend.util.request import Requests class GetRequest: @classmethod - def get_request( + async def get_request( cls, url: str, headers: Optional[dict] = None, json: bool = False ) -> Any: if headers is None: headers = {} - response = requests.get(url, headers=headers) - return response.json() if json else response.text + response = await Requests().get(url, headers=headers) + if json: + return response.json() + else: + return response.text() diff --git a/autogpt_platform/backend/backend/blocks/http.py b/autogpt_platform/backend/backend/blocks/http.py index 2adb05830960..c07c1ca5089d 100644 --- a/autogpt_platform/backend/backend/blocks/http.py +++ b/autogpt_platform/backend/backend/blocks/http.py @@ -1,10 +1,54 @@ import json +import logging from enum import Enum -from typing import Any +from io import BytesIO +from pathlib import Path +from typing import Literal + +import aiofiles +from pydantic import SecretStr from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema -from backend.data.model import SchemaField -from backend.util.request import requests +from backend.data.model import ( + CredentialsField, + CredentialsMetaInput, + HostScopedCredentials, + SchemaField, +) +from backend.integrations.providers import ProviderName +from backend.util.file import ( + MediaFileType, + get_exec_file_path, + get_mime_type, + store_media_file, +) +from backend.util.request import Requests + +logger = logging.getLogger(name=__name__) + + +# Host-scoped credentials for HTTP requests +HttpCredentials = CredentialsMetaInput[ + Literal[ProviderName.HTTP], Literal["host_scoped"] +] + + +TEST_CREDENTIALS = HostScopedCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="http", + host="api.example.com", + headers={ + "Authorization": SecretStr("Bearer test-token"), + "X-API-Key": SecretStr("test-api-key"), + }, + title="Mock HTTP Host-Scoped Credentials", +) +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} class HttpMethod(Enum): @@ -29,50 +73,192 @@ class Input(BlockSchema): ) headers: dict[str, str] = SchemaField( description="The headers to include in the request", - default={}, + default_factory=dict, ) json_format: bool = SchemaField( title="JSON format", - description="Whether to send and receive body as JSON", + description="If true, send the body as JSON (unless files are also present).", default=True, ) - body: Any = SchemaField( - description="The body of the request", + body: dict | None = SchemaField( + description="Form/JSON body payload. If files are supplied, this must be a mapping of form‑fields.", default=None, ) + files_name: str = SchemaField( + description="The name of the file field in the form data.", + default="file", + ) + files: list[MediaFileType] = SchemaField( + description="Mapping of *form field name* → Image url / path / base64 url.", + default_factory=list, + ) class Output(BlockSchema): response: object = SchemaField(description="The response from the server") - client_error: object = SchemaField(description="The error on 4xx status codes") - server_error: object = SchemaField(description="The error on 5xx status codes") + client_error: object = SchemaField(description="Errors on 4xx status codes") + server_error: object = SchemaField(description="Errors on 5xx status codes") + error: str = SchemaField(description="Errors for all other exceptions") def __init__(self): super().__init__( id="6595ae1f-b924-42cb-9a41-551a0611c4b4", - description="This block makes an HTTP request to the given URL.", + description="Make an HTTP request (JSON / form / multipart).", categories={BlockCategory.OUTPUT}, input_schema=SendWebRequestBlock.Input, output_schema=SendWebRequestBlock.Output, ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: - if isinstance(input_data.body, str): - input_data.body = json.loads(input_data.body) + @staticmethod + async def _prepare_files( + graph_exec_id: str, + files_name: str, + files: list[MediaFileType], + user_id: str, + ) -> list[tuple[str, tuple[str, BytesIO, str]]]: + """ + Prepare files for the request by storing them and reading their content. + Returns a list of tuples in the format: + (files_name, (filename, BytesIO, mime_type)) + """ + files_payload: list[tuple[str, tuple[str, BytesIO, str]]] = [] + + for media in files: + # Normalise to a list so we can repeat the same key + rel_path = await store_media_file( + graph_exec_id, media, user_id, return_content=False + ) + abs_path = get_exec_file_path(graph_exec_id, rel_path) + async with aiofiles.open(abs_path, "rb") as f: + content = await f.read() + handle = BytesIO(content) + mime = get_mime_type(abs_path) + files_payload.append((files_name, (Path(abs_path).name, handle, mime))) - response = requests.request( + return files_payload + + async def run( + self, input_data: Input, *, graph_exec_id: str, user_id: str, **kwargs + ) -> BlockOutput: + # ─── Parse/normalise body ──────────────────────────────────── + body = input_data.body + if isinstance(body, str): + try: + # Validate JSON string length to prevent DoS attacks + if len(body) > 10_000_000: # 10MB limit + raise ValueError("JSON body too large") + + parsed_body = json.loads(body) + + # Validate that parsed JSON is safe (basic object/array/primitive types) + if ( + isinstance(parsed_body, (dict, list, str, int, float, bool)) + or parsed_body is None + ): + body = parsed_body + else: + # Unexpected type, treat as plain text + input_data.json_format = False + + except (json.JSONDecodeError, ValueError): + # Invalid JSON or too large – treat as form‑field value instead + input_data.json_format = False + + # ─── Prepare files (if any) ────────────────────────────────── + use_files = bool(input_data.files) + files_payload: list[tuple[str, tuple[str, BytesIO, str]]] = [] + if use_files: + files_payload = await self._prepare_files( + graph_exec_id, input_data.files_name, input_data.files, user_id + ) + + # Enforce body format rules + if use_files and input_data.json_format: + raise ValueError( + "json_format=True cannot be combined with file uploads; set json_format=False and put form fields in `body`." + ) + + # ─── Execute request ───────────────────────────────────────── + response = await Requests().request( input_data.method.value, input_data.url, headers=input_data.headers, - json=input_data.body if input_data.json_format else None, - data=input_data.body if not input_data.json_format else None, + files=files_payload if use_files else None, + # * If files → multipart ⇒ pass form‑fields via data= + data=body if not input_data.json_format else None, + # * Else, choose JSON vs url‑encoded based on flag + json=body if (input_data.json_format and not use_files) else None, ) - result = response.json() if input_data.json_format else response.text - if response.status_code // 100 == 2: + # Decide how to parse the response + if response.headers.get("content-type", "").startswith("application/json"): + result = None if response.status == 204 else response.json() + else: + result = response.text() + + # Yield according to status code bucket + if 200 <= response.status < 300: yield "response", result - elif response.status_code // 100 == 4: + elif 400 <= response.status < 500: yield "client_error", result - elif response.status_code // 100 == 5: + else: yield "server_error", result + + +class SendAuthenticatedWebRequestBlock(SendWebRequestBlock): + class Input(SendWebRequestBlock.Input): + credentials: HttpCredentials = CredentialsField( + description="HTTP host-scoped credentials for automatic header injection", + discriminator="url", + ) + + def __init__(self): + Block.__init__( + self, + id="fff86bcd-e001-4bad-a7f6-2eae4720c8dc", + description="Make an authenticated HTTP request with host-scoped credentials (JSON / form / multipart).", + categories={BlockCategory.OUTPUT}, + input_schema=SendAuthenticatedWebRequestBlock.Input, + output_schema=SendWebRequestBlock.Output, + test_credentials=TEST_CREDENTIALS, + ) + + async def run( # type: ignore[override] + self, + input_data: Input, + *, + graph_exec_id: str, + credentials: HostScopedCredentials, + user_id: str, + **kwargs, + ) -> BlockOutput: + # Create SendWebRequestBlock.Input from our input (removing credentials field) + base_input = SendWebRequestBlock.Input( + url=input_data.url, + method=input_data.method, + headers=input_data.headers, + json_format=input_data.json_format, + body=input_data.body, + files_name=input_data.files_name, + files=input_data.files, + ) + + # Apply host-scoped credentials to headers + extra_headers = {} + if credentials.matches_url(input_data.url): + logger.debug( + f"Applying host-scoped credentials {credentials.id} for URL {input_data.url}" + ) + extra_headers.update(credentials.get_headers_dict()) else: - raise ValueError(f"Unexpected status code: {response.status_code}") + logger.warning( + f"Host-scoped credentials {credentials.id} do not match URL {input_data.url}" + ) + + # Merge with user-provided headers (user headers take precedence) + base_input.headers = {**extra_headers, **input_data.headers} + + # Use parent class run method + async for output_name, output_data in super().run( + base_input, graph_exec_id=graph_exec_id, user_id=user_id, **kwargs + ): + yield output_name, output_data diff --git a/autogpt_platform/backend/backend/blocks/hubspot/company.py b/autogpt_platform/backend/backend/blocks/hubspot/company.py index 3e9406103f57..30261122594a 100644 --- a/autogpt_platform/backend/backend/blocks/hubspot/company.py +++ b/autogpt_platform/backend/backend/blocks/hubspot/company.py @@ -5,7 +5,7 @@ ) from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField -from backend.util.request import requests +from backend.util.request import Requests class HubSpotCompanyBlock(Block): @@ -15,7 +15,8 @@ class Input(BlockSchema): description="Operation to perform (create, update, get)", default="get" ) company_data: dict = SchemaField( - description="Company data for create/update operations", default={} + description="Company data for create/update operations", + default_factory=dict, ) domain: str = SchemaField( description="Company domain for get/update operations", default="" @@ -34,7 +35,7 @@ def __init__(self): output_schema=HubSpotCompanyBlock.Output, ) - def run( + async def run( self, input_data: Input, *, credentials: HubSpotCredentials, **kwargs ) -> BlockOutput: base_url = "https://api.hubapi.com/crm/v3/objects/companies" @@ -44,7 +45,7 @@ def run( } if input_data.operation == "create": - response = requests.post( + response = await Requests().post( base_url, headers=headers, json={"properties": input_data.company_data} ) result = response.json() @@ -66,14 +67,16 @@ def run( } ] } - response = requests.post(search_url, headers=headers, json=search_data) - result = response.json() - yield "company", result.get("results", [{}])[0] + search_response = await Requests().post( + search_url, headers=headers, json=search_data + ) + search_result = search_response.json() + yield "search_company", search_result.get("results", [{}])[0] yield "status", "retrieved" elif input_data.operation == "update": # First get company ID by domain - search_response = requests.post( + search_response = await Requests().post( f"{base_url}/search", headers=headers, json={ @@ -90,10 +93,11 @@ def run( ] }, ) - company_id = search_response.json().get("results", [{}])[0].get("id") + search_result = search_response.json() + company_id = search_result.get("results", [{}])[0].get("id") if company_id: - response = requests.patch( + response = await Requests().patch( f"{base_url}/{company_id}", headers=headers, json={"properties": input_data.company_data}, diff --git a/autogpt_platform/backend/backend/blocks/hubspot/contact.py b/autogpt_platform/backend/backend/blocks/hubspot/contact.py index e4a01cbb3bd4..2029adaca13f 100644 --- a/autogpt_platform/backend/backend/blocks/hubspot/contact.py +++ b/autogpt_platform/backend/backend/blocks/hubspot/contact.py @@ -5,7 +5,7 @@ ) from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField -from backend.util.request import requests +from backend.util.request import Requests class HubSpotContactBlock(Block): @@ -15,7 +15,8 @@ class Input(BlockSchema): description="Operation to perform (create, update, get)", default="get" ) contact_data: dict = SchemaField( - description="Contact data for create/update operations", default={} + description="Contact data for create/update operations", + default_factory=dict, ) email: str = SchemaField( description="Email address for get/update operations", default="" @@ -34,7 +35,7 @@ def __init__(self): output_schema=HubSpotContactBlock.Output, ) - def run( + async def run( self, input_data: Input, *, credentials: HubSpotCredentials, **kwargs ) -> BlockOutput: base_url = "https://api.hubapi.com/crm/v3/objects/contacts" @@ -44,7 +45,7 @@ def run( } if input_data.operation == "create": - response = requests.post( + response = await Requests().post( base_url, headers=headers, json={"properties": input_data.contact_data} ) result = response.json() @@ -52,7 +53,6 @@ def run( yield "status", "created" elif input_data.operation == "get": - # Search for contact by email search_url = f"{base_url}/search" search_data = { "filterGroups": [ @@ -67,13 +67,15 @@ def run( } ] } - response = requests.post(search_url, headers=headers, json=search_data) + response = await Requests().post( + search_url, headers=headers, json=search_data + ) result = response.json() yield "contact", result.get("results", [{}])[0] yield "status", "retrieved" elif input_data.operation == "update": - search_response = requests.post( + search_response = await Requests().post( f"{base_url}/search", headers=headers, json={ @@ -90,10 +92,11 @@ def run( ] }, ) - contact_id = search_response.json().get("results", [{}])[0].get("id") + search_result = search_response.json() + contact_id = search_result.get("results", [{}])[0].get("id") if contact_id: - response = requests.patch( + response = await Requests().patch( f"{base_url}/{contact_id}", headers=headers, json={"properties": input_data.contact_data}, diff --git a/autogpt_platform/backend/backend/blocks/hubspot/engagement.py b/autogpt_platform/backend/backend/blocks/hubspot/engagement.py index 427cf051d599..7e4dbc3d019d 100644 --- a/autogpt_platform/backend/backend/blocks/hubspot/engagement.py +++ b/autogpt_platform/backend/backend/blocks/hubspot/engagement.py @@ -7,7 +7,7 @@ ) from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField -from backend.util.request import requests +from backend.util.request import Requests class HubSpotEngagementBlock(Block): @@ -19,7 +19,7 @@ class Input(BlockSchema): ) email_data: dict = SchemaField( description="Email data including recipient, subject, content", - default={}, + default_factory=dict, ) contact_id: str = SchemaField( description="Contact ID for engagement tracking", default="" @@ -27,7 +27,6 @@ class Input(BlockSchema): timeframe_days: int = SchemaField( description="Number of days to look back for engagement", default=30, - optional=True, ) class Output(BlockSchema): @@ -43,7 +42,7 @@ def __init__(self): output_schema=HubSpotEngagementBlock.Output, ) - def run( + async def run( self, input_data: Input, *, credentials: HubSpotCredentials, **kwargs ) -> BlockOutput: base_url = "https://api.hubapi.com" @@ -67,7 +66,9 @@ def run( } } - response = requests.post(email_url, headers=headers, json=email_data) + response = await Requests().post( + email_url, headers=headers, json=email_data + ) result = response.json() yield "result", result yield "status", "email_sent" @@ -81,7 +82,9 @@ def run( params = {"limit": 100, "after": from_date.isoformat()} - response = requests.get(engagement_url, headers=headers, params=params) + response = await Requests().get( + engagement_url, headers=headers, params=params + ) engagements = response.json() # Process engagement metrics diff --git a/autogpt_platform/backend/backend/blocks/ideogram.py b/autogpt_platform/backend/backend/blocks/ideogram.py index 82eb91238b40..ef5aca248921 100644 --- a/autogpt_platform/backend/backend/blocks/ideogram.py +++ b/autogpt_platform/backend/backend/blocks/ideogram.py @@ -12,7 +12,7 @@ SchemaField, ) from backend.integrations.providers import ProviderName -from backend.util.request import requests +from backend.util.request import Requests TEST_CREDENTIALS = APIKeyCredentials( id="01234567-89ab-cdef-0123-456789abcdef", @@ -30,6 +30,7 @@ class IdeogramModelName(str, Enum): + V3 = "V_3" V2 = "V_2" V1 = "V_1" V1_TURBO = "V_1_TURBO" @@ -95,8 +96,8 @@ class Input(BlockSchema): title="Prompt", ) ideogram_model_name: IdeogramModelName = SchemaField( - description="The name of the Image Generation Model, e.g., V_2", - default=IdeogramModelName.V2, + description="The name of the Image Generation Model, e.g., V_3", + default=IdeogramModelName.V3, title="Image Generation Model", advanced=False, ) @@ -142,6 +143,16 @@ class Input(BlockSchema): title="Color Palette Preset", advanced=True, ) + custom_color_palette: Optional[list[str]] = SchemaField( + description=( + "Only available for model version V_2 or V_2_TURBO. Provide one or more color hex codes " + "(e.g., ['#000030', '#1C0C47', '#9900FF', '#4285F4', '#FFFFFF']) to define a custom color " + "palette. Only used if 'color_palette_name' is 'NONE'." + ), + default=None, + title="Custom Color Palette", + advanced=True, + ) class Output(BlockSchema): result: str = SchemaField(description="Generated image URL") @@ -151,7 +162,7 @@ def __init__(self): super().__init__( id="6ab085e2-20b3-4055-bc3e-08036e01eca6", description="This block runs Ideogram models with both simple and advanced settings.", - categories={BlockCategory.AI}, + categories={BlockCategory.AI, BlockCategory.MULTIMEDIA}, input_schema=IdeogramModelBlock.Input, output_schema=IdeogramModelBlock.Output, test_input={ @@ -164,6 +175,13 @@ def __init__(self): "style_type": StyleType.AUTO, "negative_prompt": None, "color_palette_name": ColorPalettePreset.NONE, + "custom_color_palette": [ + "#000030", + "#1C0C47", + "#9900FF", + "#4285F4", + "#FFFFFF", + ], "credentials": TEST_CREDENTIALS_INPUT, }, test_output=[ @@ -173,19 +191,19 @@ def __init__(self): ), ], test_mock={ - "run_model": lambda api_key, model_name, prompt, seed, aspect_ratio, magic_prompt_option, style_type, negative_prompt, color_palette_name: "https://ideogram.ai/api/images/test-generated-image-url.png", + "run_model": lambda api_key, model_name, prompt, seed, aspect_ratio, magic_prompt_option, style_type, negative_prompt, color_palette_name, custom_colors: "https://ideogram.ai/api/images/test-generated-image-url.png", "upscale_image": lambda api_key, image_url: "https://ideogram.ai/api/images/test-upscaled-image-url.png", }, test_credentials=TEST_CREDENTIALS, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: seed = input_data.seed # Step 1: Generate the image - result = self.run_model( + result = await self.run_model( api_key=credentials.api_key, model_name=input_data.ideogram_model_name.value, prompt=input_data.prompt, @@ -195,18 +213,19 @@ def run( style_type=input_data.style_type.value, negative_prompt=input_data.negative_prompt, color_palette_name=input_data.color_palette_name.value, + custom_colors=input_data.custom_color_palette, ) # Step 2: Upscale the image if requested if input_data.upscale == UpscaleOption.AI_UPSCALE: - result = self.upscale_image( + result = await self.upscale_image( api_key=credentials.api_key, image_url=result, ) yield "result", result - def run_model( + async def run_model( self, api_key: SecretStr, model_name: str, @@ -217,6 +236,112 @@ def run_model( style_type: str, negative_prompt: Optional[str], color_palette_name: str, + custom_colors: Optional[list[str]], + ): + # Use V3 endpoint for V3 model, legacy endpoint for others + if model_name == "V_3": + return await self._run_model_v3( + api_key, + prompt, + seed, + aspect_ratio, + magic_prompt_option, + style_type, + negative_prompt, + color_palette_name, + custom_colors, + ) + else: + return await self._run_model_legacy( + api_key, + model_name, + prompt, + seed, + aspect_ratio, + magic_prompt_option, + style_type, + negative_prompt, + color_palette_name, + custom_colors, + ) + + async def _run_model_v3( + self, + api_key: SecretStr, + prompt: str, + seed: Optional[int], + aspect_ratio: str, + magic_prompt_option: str, + style_type: str, + negative_prompt: Optional[str], + color_palette_name: str, + custom_colors: Optional[list[str]], + ): + url = "https://api.ideogram.ai/v1/ideogram-v3/generate" + headers = { + "Api-Key": api_key.get_secret_value(), + "Content-Type": "application/json", + } + + # Map legacy aspect ratio values to V3 format + aspect_ratio_map = { + "ASPECT_10_16": "10x16", + "ASPECT_16_10": "16x10", + "ASPECT_9_16": "9x16", + "ASPECT_16_9": "16x9", + "ASPECT_3_2": "3x2", + "ASPECT_2_3": "2x3", + "ASPECT_4_3": "4x3", + "ASPECT_3_4": "3x4", + "ASPECT_1_1": "1x1", + "ASPECT_1_3": "1x3", + "ASPECT_3_1": "3x1", + # Additional V3 supported ratios + "ASPECT_1_2": "1x2", + "ASPECT_2_1": "2x1", + "ASPECT_4_5": "4x5", + "ASPECT_5_4": "5x4", + } + + v3_aspect_ratio = aspect_ratio_map.get( + aspect_ratio, "1x1" + ) # Default to 1x1 if not found + + # Use JSON for V3 endpoint (simpler than multipart/form-data) + data: Dict[str, Any] = { + "prompt": prompt, + "aspect_ratio": v3_aspect_ratio, + "magic_prompt": magic_prompt_option, + "style_type": style_type, + } + + if seed is not None: + data["seed"] = seed + + if negative_prompt: + data["negative_prompt"] = negative_prompt + + # Note: V3 endpoint may have different color palette support + # For now, we'll omit color palettes for V3 to avoid errors + + try: + response = await Requests().post(url, headers=headers, json=data) + return response.json()["data"][0]["url"] + except RequestException as e: + raise Exception(f"Failed to fetch image with V3 endpoint: {str(e)}") + + async def _run_model_legacy( + self, + api_key: SecretStr, + model_name: str, + prompt: str, + seed: Optional[int], + aspect_ratio: str, + magic_prompt_option: str, + style_type: str, + negative_prompt: Optional[str], + color_palette_name: str, + custom_colors: Optional[list[str]], ): url = "https://api.ideogram.ai/generate" headers = { @@ -230,26 +355,35 @@ def run_model( "model": model_name, "aspect_ratio": aspect_ratio, "magic_prompt_option": magic_prompt_option, - "style_type": style_type, } } + # Only add style_type for V2, V2_TURBO, and V3 models (V1 models don't support it) + if model_name in ["V_2", "V_2_TURBO", "V_3"]: + data["image_request"]["style_type"] = style_type + if seed is not None: data["image_request"]["seed"] = seed if negative_prompt: data["image_request"]["negative_prompt"] = negative_prompt - if color_palette_name != "NONE": - data["image_request"]["color_palette"] = {"name": color_palette_name} + # Only add color palette for V2 and V2_TURBO models (V1 models don't support it) + if model_name in ["V_2", "V_2_TURBO"]: + if color_palette_name != "NONE": + data["color_palette"] = {"name": color_palette_name} + elif custom_colors: + data["color_palette"] = { + "members": [{"color_hex": color} for color in custom_colors] + } try: - response = requests.post(url, json=data, headers=headers) + response = await Requests().post(url, headers=headers, json=data) return response.json()["data"][0]["url"] except RequestException as e: - raise Exception(f"Failed to fetch image: {str(e)}") + raise Exception(f"Failed to fetch image with legacy endpoint: {str(e)}") - def upscale_image(self, api_key: SecretStr, image_url: str): + async def upscale_image(self, api_key: SecretStr, image_url: str): url = "https://api.ideogram.ai/upscale" headers = { "Api-Key": api_key.get_secret_value(), @@ -257,23 +391,22 @@ def upscale_image(self, api_key: SecretStr, image_url: str): try: # Step 1: Download the image from the provided URL - image_response = requests.get(image_url) + response = await Requests().get(image_url) + image_content = response.content # Step 2: Send the downloaded image to the upscale API files = { - "image_file": ("image.png", image_response.content, "image/png"), + "image_file": ("image.png", image_content, "image/png"), } - response = requests.post( + response = await Requests().post( url, headers=headers, - data={ - "image_request": "{}", # Empty JSON object - }, + data={"image_request": "{}"}, files=files, ) - return response.json()["data"][0]["url"] + return (response.json())["data"][0]["url"] except RequestException as e: raise Exception(f"Failed to upscale image: {str(e)}") diff --git a/autogpt_platform/backend/backend/blocks/io.py b/autogpt_platform/backend/backend/blocks/io.py new file mode 100644 index 000000000000..c9a98c3ca957 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/io.py @@ -0,0 +1,563 @@ +import copy +from datetime import date, time +from typing import Any, Optional + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema, BlockType +from backend.data.model import SchemaField +from backend.util.file import store_media_file +from backend.util.mock import MockObject +from backend.util.settings import Config +from backend.util.text import TextFormatter +from backend.util.type import LongTextType, MediaFileType, ShortTextType + +formatter = TextFormatter() +config = Config() + + +class AgentInputBlock(Block): + """ + This block is used to provide input to the graph. + + It takes in a value, name, description, default values list and bool to limit selection to default values. + + It Outputs the value passed as input. + """ + + class Input(BlockSchema): + name: str = SchemaField(description="The name of the input.") + value: Any = SchemaField( + description="The value to be passed as input.", + default=None, + ) + title: str | None = SchemaField( + description="The title of the input.", default=None, advanced=True + ) + description: str | None = SchemaField( + description="The description of the input.", + default=None, + advanced=True, + ) + placeholder_values: list = SchemaField( + description="The placeholder values to be passed as input.", + default_factory=list, + advanced=True, + hidden=True, + ) + advanced: bool = SchemaField( + description="Whether to show the input in the advanced section, if the field is not required.", + default=False, + advanced=True, + ) + secret: bool = SchemaField( + description="Whether the input should be treated as a secret.", + default=False, + advanced=True, + ) + + def generate_schema(self): + schema = copy.deepcopy(self.get_field_schema("value")) + if possible_values := self.placeholder_values: + schema["enum"] = possible_values + return schema + + class Output(BlockSchema): + result: Any = SchemaField(description="The value passed as input.") + + def __init__(self, **kwargs): + super().__init__( + **{ + "id": "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b", + "description": "Base block for user inputs.", + "input_schema": AgentInputBlock.Input, + "output_schema": AgentInputBlock.Output, + "test_input": [ + { + "value": "Hello, World!", + "name": "input_1", + "description": "Example test input.", + "placeholder_values": [], + }, + { + "value": "Hello, World!", + "name": "input_2", + "description": "Example test input with placeholders.", + "placeholder_values": ["Hello, World!"], + }, + ], + "test_output": [ + ("result", "Hello, World!"), + ("result", "Hello, World!"), + ], + "categories": {BlockCategory.INPUT, BlockCategory.BASIC}, + "block_type": BlockType.INPUT, + "static_output": True, + **kwargs, + } + ) + + async def run(self, input_data: Input, *args, **kwargs) -> BlockOutput: + if input_data.value is not None: + yield "result", input_data.value + + +class AgentOutputBlock(Block): + """ + Records the output of the graph for users to see. + + Behavior: + If `format` is provided and the `value` is of a type that can be formatted, + the block attempts to format the recorded_value using the `format`. + If formatting fails or no `format` is provided, the raw `value` is output. + """ + + class Input(BlockSchema): + value: Any = SchemaField( + description="The value to be recorded as output.", + default=None, + advanced=False, + ) + name: str = SchemaField(description="The name of the output.") + title: str | None = SchemaField( + description="The title of the output.", + default=None, + advanced=True, + ) + description: str | None = SchemaField( + description="The description of the output.", + default=None, + advanced=True, + ) + format: str = SchemaField( + description="The format string to be used to format the recorded_value. Use Jinja2 syntax.", + default="", + advanced=True, + ) + advanced: bool = SchemaField( + description="Whether to treat the output as advanced.", + default=False, + advanced=True, + ) + secret: bool = SchemaField( + description="Whether the output should be treated as a secret.", + default=False, + advanced=True, + ) + + def generate_schema(self): + return self.get_field_schema("value") + + class Output(BlockSchema): + output: Any = SchemaField(description="The value recorded as output.") + name: Any = SchemaField(description="The name of the value recorded as output.") + + def __init__(self): + super().__init__( + id="363ae599-353e-4804-937e-b2ee3cef3da4", + description="Stores the output of the graph for users to see.", + input_schema=AgentOutputBlock.Input, + output_schema=AgentOutputBlock.Output, + test_input=[ + { + "value": "Hello, World!", + "name": "output_1", + "description": "This is a test output.", + "format": "{{ output_1 }}!!", + }, + { + "value": "42", + "name": "output_2", + "description": "This is another test output.", + "format": "{{ output_2 }}", + }, + { + "value": MockObject(value="!!", key="key"), + "name": "output_3", + "description": "This is a test output with a mock object.", + "format": "{{ output_3 }}", + }, + ], + test_output=[ + ("output", "Hello, World!!!"), + ("output", "42"), + ("output", MockObject(value="!!", key="key")), + ], + categories={BlockCategory.OUTPUT, BlockCategory.BASIC}, + block_type=BlockType.OUTPUT, + static_output=True, + ) + + async def run(self, input_data: Input, *args, **kwargs) -> BlockOutput: + """ + Attempts to format the recorded_value using the fmt_string if provided. + If formatting fails or no fmt_string is given, returns the original recorded_value. + """ + if input_data.format: + try: + yield "output", formatter.format_string( + input_data.format, {input_data.name: input_data.value} + ) + except Exception as e: + yield "output", f"Error: {e}, {input_data.value}" + else: + yield "output", input_data.value + yield "name", input_data.name + + +class AgentShortTextInputBlock(AgentInputBlock): + class Input(AgentInputBlock.Input): + value: Optional[ShortTextType] = SchemaField( + description="Short text input.", + default=None, + advanced=False, + title="Default Value", + ) + + class Output(AgentInputBlock.Output): + result: str = SchemaField(description="Short text result.") + + def __init__(self): + super().__init__( + id="7fcd3bcb-8e1b-4e69-903d-32d3d4a92158", + description="Block for short text input (single-line).", + disabled=not config.enable_agent_input_subtype_blocks, + input_schema=AgentShortTextInputBlock.Input, + output_schema=AgentShortTextInputBlock.Output, + test_input=[ + { + "value": "Hello", + "name": "short_text_1", + "description": "Short text example 1", + "placeholder_values": [], + }, + { + "value": "Quick test", + "name": "short_text_2", + "description": "Short text example 2", + "placeholder_values": ["Quick test", "Another option"], + }, + ], + test_output=[ + ("result", "Hello"), + ("result", "Quick test"), + ], + ) + + +class AgentLongTextInputBlock(AgentInputBlock): + class Input(AgentInputBlock.Input): + value: Optional[LongTextType] = SchemaField( + description="Long text input (potentially multi-line).", + default=None, + advanced=False, + title="Default Value", + ) + + class Output(AgentInputBlock.Output): + result: str = SchemaField(description="Long text result.") + + def __init__(self): + super().__init__( + id="90a56ffb-7024-4b2b-ab50-e26c5e5ab8ba", + description="Block for long text input (multi-line).", + disabled=not config.enable_agent_input_subtype_blocks, + input_schema=AgentLongTextInputBlock.Input, + output_schema=AgentLongTextInputBlock.Output, + test_input=[ + { + "value": "Lorem ipsum dolor sit amet...", + "name": "long_text_1", + "description": "Long text example 1", + "placeholder_values": [], + }, + { + "value": "Another multiline text input.", + "name": "long_text_2", + "description": "Long text example 2", + "placeholder_values": ["Another multiline text input."], + }, + ], + test_output=[ + ("result", "Lorem ipsum dolor sit amet..."), + ("result", "Another multiline text input."), + ], + ) + + +class AgentNumberInputBlock(AgentInputBlock): + class Input(AgentInputBlock.Input): + value: Optional[int] = SchemaField( + description="Number input.", + default=None, + advanced=False, + title="Default Value", + ) + + class Output(AgentInputBlock.Output): + result: int = SchemaField(description="Number result.") + + def __init__(self): + super().__init__( + id="96dae2bb-97a2-41c2-bd2f-13a3b5a8ea98", + description="Block for number input.", + disabled=not config.enable_agent_input_subtype_blocks, + input_schema=AgentNumberInputBlock.Input, + output_schema=AgentNumberInputBlock.Output, + test_input=[ + { + "value": 42, + "name": "number_input_1", + "description": "Number example 1", + "placeholder_values": [], + }, + { + "value": 314, + "name": "number_input_2", + "description": "Number example 2", + "placeholder_values": [314, 2718], + }, + ], + test_output=[ + ("result", 42), + ("result", 314), + ], + ) + + +class AgentDateInputBlock(AgentInputBlock): + class Input(AgentInputBlock.Input): + value: Optional[date] = SchemaField( + description="Date input (YYYY-MM-DD).", + default=None, + advanced=False, + title="Default Value", + ) + + class Output(AgentInputBlock.Output): + result: date = SchemaField(description="Date result.") + + def __init__(self): + super().__init__( + id="7e198b09-4994-47db-8b4d-952d98241817", + description="Block for date input.", + disabled=not config.enable_agent_input_subtype_blocks, + input_schema=AgentDateInputBlock.Input, + output_schema=AgentDateInputBlock.Output, + test_input=[ + { + # If your system can parse JSON date strings to date objects + "value": str(date(2025, 3, 19)), + "name": "date_input_1", + "description": "Example date input 1", + }, + { + "value": str(date(2023, 12, 31)), + "name": "date_input_2", + "description": "Example date input 2", + }, + ], + test_output=[ + ("result", date(2025, 3, 19)), + ("result", date(2023, 12, 31)), + ], + ) + + +class AgentTimeInputBlock(AgentInputBlock): + class Input(AgentInputBlock.Input): + value: Optional[time] = SchemaField( + description="Time input (HH:MM:SS).", + default=None, + advanced=False, + title="Default Value", + ) + + class Output(AgentInputBlock.Output): + result: time = SchemaField(description="Time result.") + + def __init__(self): + super().__init__( + id="2a1c757e-86cf-4c7e-aacf-060dc382e434", + description="Block for time input.", + disabled=not config.enable_agent_input_subtype_blocks, + input_schema=AgentTimeInputBlock.Input, + output_schema=AgentTimeInputBlock.Output, + test_input=[ + { + "value": str(time(9, 30, 0)), + "name": "time_input_1", + "description": "Time example 1", + }, + { + "value": str(time(23, 59, 59)), + "name": "time_input_2", + "description": "Time example 2", + }, + ], + test_output=[ + ("result", time(9, 30, 0)), + ("result", time(23, 59, 59)), + ], + ) + + +class AgentFileInputBlock(AgentInputBlock): + """ + A simplified file-upload block. In real usage, you might have a custom + file type or handle binary data. Here, we'll store a string path as the example. + """ + + class Input(AgentInputBlock.Input): + value: Optional[MediaFileType] = SchemaField( + description="Path or reference to an uploaded file.", + default=None, + advanced=False, + title="Default Value", + ) + base_64: bool = SchemaField( + description="Whether produce an output in base64 format (not recommended, you can pass the string path just fine accross blocks).", + default=False, + advanced=True, + title="Produce Base64 Output", + ) + + class Output(AgentInputBlock.Output): + result: str = SchemaField(description="File reference/path result.") + + def __init__(self): + super().__init__( + id="95ead23f-8283-4654-aef3-10c053b74a31", + description="Block for file upload input (string path for example).", + disabled=not config.enable_agent_input_subtype_blocks, + input_schema=AgentFileInputBlock.Input, + output_schema=AgentFileInputBlock.Output, + test_input=[ + { + "value": "data:image/png;base64,MQ==", + "name": "file_upload_1", + "description": "Example file upload 1", + }, + ], + test_output=[ + ("result", str), + ], + ) + + async def run( + self, + input_data: Input, + *, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + if not input_data.value: + return + + yield "result", await store_media_file( + graph_exec_id=graph_exec_id, + file=input_data.value, + user_id=user_id, + return_content=input_data.base_64, + ) + + +class AgentDropdownInputBlock(AgentInputBlock): + """ + A specialized text input block that relies on placeholder_values to present a dropdown. + """ + + class Input(AgentInputBlock.Input): + value: Optional[str] = SchemaField( + description="Text selected from a dropdown.", + default=None, + advanced=False, + title="Default Value", + ) + placeholder_values: list = SchemaField( + description="Possible values for the dropdown.", + default_factory=list, + advanced=False, + title="Dropdown Options", + ) + + class Output(AgentInputBlock.Output): + result: str = SchemaField(description="Selected dropdown value.") + + def __init__(self): + super().__init__( + id="655d6fdf-a334-421c-b733-520549c07cd1", + description="Block for dropdown text selection.", + disabled=not config.enable_agent_input_subtype_blocks, + input_schema=AgentDropdownInputBlock.Input, + output_schema=AgentDropdownInputBlock.Output, + test_input=[ + { + "value": "Option A", + "name": "dropdown_1", + "placeholder_values": ["Option A", "Option B", "Option C"], + "description": "Dropdown example 1", + }, + { + "value": "Option C", + "name": "dropdown_2", + "placeholder_values": ["Option A", "Option B", "Option C"], + "description": "Dropdown example 2", + }, + ], + test_output=[ + ("result", "Option A"), + ("result", "Option C"), + ], + ) + + +class AgentToggleInputBlock(AgentInputBlock): + class Input(AgentInputBlock.Input): + value: bool = SchemaField( + description="Boolean toggle input.", + default=False, + advanced=False, + title="Default Value", + ) + + class Output(AgentInputBlock.Output): + result: bool = SchemaField(description="Boolean toggle result.") + + def __init__(self): + super().__init__( + id="cbf36ab5-df4a-43b6-8a7f-f7ed8652116e", + description="Block for boolean toggle input.", + disabled=not config.enable_agent_input_subtype_blocks, + input_schema=AgentToggleInputBlock.Input, + output_schema=AgentToggleInputBlock.Output, + test_input=[ + { + "value": True, + "name": "toggle_1", + "description": "Toggle example 1", + }, + { + "value": False, + "name": "toggle_2", + "description": "Toggle example 2", + }, + ], + test_output=[ + ("result", True), + ("result", False), + ], + ) + + +IO_BLOCK_IDs = [ + AgentInputBlock().id, + AgentOutputBlock().id, + AgentShortTextInputBlock().id, + AgentLongTextInputBlock().id, + AgentNumberInputBlock().id, + AgentDateInputBlock().id, + AgentTimeInputBlock().id, + AgentFileInputBlock().id, + AgentDropdownInputBlock().id, + AgentToggleInputBlock().id, +] diff --git a/autogpt_platform/backend/backend/blocks/iteration.py b/autogpt_platform/backend/backend/blocks/iteration.py index 16783af59f11..c0b66a2ed008 100644 --- a/autogpt_platform/backend/backend/blocks/iteration.py +++ b/autogpt_platform/backend/backend/blocks/iteration.py @@ -11,13 +11,13 @@ class Input(BlockSchema): advanced=False, description="The list or dictionary of items to iterate over", placeholder="[1, 2, 3, 4, 5] or {'key1': 'value1', 'key2': 'value2'}", - default=[], + default_factory=list, ) items_object: dict = SchemaField( advanced=False, description="The list or dictionary of items to iterate over", placeholder="[1, 2, 3, 4, 5] or {'key1': 'value1', 'key2': 'value2'}", - default={}, + default_factory=dict, ) items_str: str = SchemaField( advanced=False, @@ -53,7 +53,7 @@ def __init__(self): test_mock={}, ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: for data in [input_data.items, input_data.items_object, input_data.items_str]: if not data: continue diff --git a/autogpt_platform/backend/backend/blocks/jina/chunking.py b/autogpt_platform/backend/backend/blocks/jina/chunking.py index 24102e560fb7..052fa8e81529 100644 --- a/autogpt_platform/backend/backend/blocks/jina/chunking.py +++ b/autogpt_platform/backend/backend/blocks/jina/chunking.py @@ -5,7 +5,7 @@ ) from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField -from backend.util.request import requests +from backend.util.request import Requests class JinaChunkingBlock(Block): @@ -23,7 +23,7 @@ class Input(BlockSchema): class Output(BlockSchema): chunks: list = SchemaField(description="List of chunked texts") tokens: list = SchemaField( - description="List of token information for each chunk", optional=True + description="List of token information for each chunk", ) def __init__(self): @@ -35,7 +35,7 @@ def __init__(self): output_schema=JinaChunkingBlock.Output, ) - def run( + async def run( self, input_data: Input, *, credentials: JinaCredentials, **kwargs ) -> BlockOutput: url = "https://segment.jina.ai/" @@ -55,7 +55,7 @@ def run( "max_chunk_length": str(input_data.max_chunk_length), } - response = requests.post(url, headers=headers, json=data) + response = await Requests().post(url, headers=headers, json=data) result = response.json() all_chunks.extend(result.get("chunks", [])) diff --git a/autogpt_platform/backend/backend/blocks/jina/embeddings.py b/autogpt_platform/backend/backend/blocks/jina/embeddings.py index 67a17bf2c34f..abc2f9d6aef7 100644 --- a/autogpt_platform/backend/backend/blocks/jina/embeddings.py +++ b/autogpt_platform/backend/backend/blocks/jina/embeddings.py @@ -5,7 +5,7 @@ ) from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField -from backend.util.request import requests +from backend.util.request import Requests class JinaEmbeddingBlock(Block): @@ -29,7 +29,7 @@ def __init__(self): output_schema=JinaEmbeddingBlock.Output, ) - def run( + async def run( self, input_data: Input, *, credentials: JinaCredentials, **kwargs ) -> BlockOutput: url = "https://api.jina.ai/v1/embeddings" @@ -38,6 +38,6 @@ def run( "Authorization": f"Bearer {credentials.api_key.get_secret_value()}", } data = {"input": input_data.texts, "model": input_data.model} - response = requests.post(url, headers=headers, json=data) + response = await Requests().post(url, headers=headers, json=data) embeddings = [e["embedding"] for e in response.json()["data"]] yield "embeddings", embeddings diff --git a/autogpt_platform/backend/backend/blocks/jina/fact_checker.py b/autogpt_platform/backend/backend/blocks/jina/fact_checker.py index c9b8c08d1db8..9cf1e277fd41 100644 --- a/autogpt_platform/backend/backend/blocks/jina/fact_checker.py +++ b/autogpt_platform/backend/backend/blocks/jina/fact_checker.py @@ -1,7 +1,5 @@ from urllib.parse import quote -import requests - from backend.blocks.jina._auth import ( JinaCredentials, JinaCredentialsField, @@ -9,6 +7,7 @@ ) from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField +from backend.util.request import Requests class FactCheckerBlock(Block): @@ -35,7 +34,7 @@ def __init__(self): output_schema=FactCheckerBlock.Output, ) - def run( + async def run( self, input_data: Input, *, credentials: JinaCredentials, **kwargs ) -> BlockOutput: encoded_statement = quote(input_data.statement) @@ -46,8 +45,7 @@ def run( "Authorization": f"Bearer {credentials.api_key.get_secret_value()}", } - response = requests.get(url, headers=headers) - response.raise_for_status() + response = await Requests().get(url, headers=headers) data = response.json() if "data" in data: diff --git a/autogpt_platform/backend/backend/blocks/jina/search.py b/autogpt_platform/backend/backend/blocks/jina/search.py index c03ca3ce0134..90a6eea51cd6 100644 --- a/autogpt_platform/backend/backend/blocks/jina/search.py +++ b/autogpt_platform/backend/backend/blocks/jina/search.py @@ -1,4 +1,4 @@ -from groq._utils._utils import quote +from urllib.parse import quote from backend.blocks.jina._auth import ( TEST_CREDENTIALS, @@ -39,7 +39,7 @@ def __init__(self): test_mock={"get_request": lambda *args, **kwargs: "search content"}, ) - def run( + async def run( self, input_data: Input, *, credentials: JinaCredentials, **kwargs ) -> BlockOutput: # Encode the search query @@ -51,7 +51,7 @@ def run( # Prepend the Jina Search URL to the encoded query jina_search_url = f"https://s.jina.ai/{encoded_query}" - results = self.get_request(jina_search_url, headers=headers, json=False) + results = await self.get_request(jina_search_url, headers=headers, json=False) # Output the search results yield "results", results @@ -90,7 +90,7 @@ def __init__(self): test_mock={"get_request": lambda *args, **kwargs: "scraped content"}, ) - def run( + async def run( self, input_data: Input, *, credentials: JinaCredentials, **kwargs ) -> BlockOutput: if input_data.raw_content: @@ -103,5 +103,5 @@ def run( "Authorization": f"Bearer {credentials.api_key.get_secret_value()}", } - content = self.get_request(url, json=False, headers=headers) + content = await self.get_request(url, json=False, headers=headers) yield "content", content diff --git a/autogpt_platform/backend/backend/blocks/linear/__init__.py b/autogpt_platform/backend/backend/blocks/linear/__init__.py new file mode 100644 index 000000000000..1109105aaf0d --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/__init__.py @@ -0,0 +1,14 @@ +""" +Linear integration blocks for AutoGPT Platform. +""" + +from .comment import LinearCreateCommentBlock +from .issues import LinearCreateIssueBlock, LinearSearchIssuesBlock +from .projects import LinearSearchProjectsBlock + +__all__ = [ + "LinearCreateCommentBlock", + "LinearCreateIssueBlock", + "LinearSearchIssuesBlock", + "LinearSearchProjectsBlock", +] diff --git a/autogpt_platform/backend/backend/blocks/linear/_api.py b/autogpt_platform/backend/backend/blocks/linear/_api.py new file mode 100644 index 000000000000..d79aaa39b41a --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/_api.py @@ -0,0 +1,267 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, Optional, Union + +from backend.sdk import APIKeyCredentials, OAuth2Credentials, Requests + +from .models import CreateCommentResponse, CreateIssueResponse, Issue, Project + + +class LinearAPIException(Exception): + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.status_code = status_code + + +class LinearClient: + """Client for the Linear API + + If you're looking for the schema: https://studio.apollographql.com/public/Linear-API/variant/current/schema + """ + + API_URL = "https://api.linear.app/graphql" + + def __init__( + self, + credentials: Union[OAuth2Credentials, APIKeyCredentials, None] = None, + custom_requests: Optional[Requests] = None, + ): + if custom_requests: + self._requests = custom_requests + else: + headers: Dict[str, str] = { + "Content-Type": "application/json", + } + if credentials: + headers["Authorization"] = credentials.auth_header() + + self._requests = Requests( + extra_headers=headers, + trusted_origins=["https://api.linear.app"], + raise_for_status=False, + ) + + async def _execute_graphql_request( + self, query: str, variables: dict | None = None + ) -> Any: + """ + Executes a GraphQL request against the Linear API and returns the response data. + + Args: + query: The GraphQL query string. + variables (optional): Any GraphQL query variables + + Returns: + The parsed JSON response data, or raises a LinearAPIException on error. + """ + payload: Dict[str, Any] = {"query": query} + if variables: + payload["variables"] = variables + + response = await self._requests.post(self.API_URL, json=payload) + + if not response.ok: + try: + error_data = response.json() + error_message = error_data.get("errors", [{}])[0].get("message", "") + except json.JSONDecodeError: + error_message = response.text() + + raise LinearAPIException( + f"Linear API request failed ({response.status}): {error_message}", + response.status, + ) + + response_data = response.json() + if "errors" in response_data: + + error_messages = [ + error.get("message", "") for error in response_data["errors"] + ] + raise LinearAPIException( + f"Linear API returned errors: {', '.join(error_messages)}", + response.status, + ) + + return response_data["data"] + + async def query(self, query: str, variables: Optional[dict] = None) -> dict: + """Executes a GraphQL query. + + Args: + query: The GraphQL query string. + variables: Query variables, if any. + + Returns: + The response data. + """ + return await self._execute_graphql_request(query, variables) + + async def mutate(self, mutation: str, variables: Optional[dict] = None) -> dict: + """Executes a GraphQL mutation. + + Args: + mutation: The GraphQL mutation string. + variables: Query variables, if any. + + Returns: + The response data. + """ + return await self._execute_graphql_request(mutation, variables) + + async def try_create_comment( + self, issue_id: str, comment: str + ) -> CreateCommentResponse: + try: + mutation = """ + mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + success + comment { + id + body + } + } + } + """ + + variables = { + "input": { + "body": comment, + "issueId": issue_id, + } + } + + added_comment = await self.mutate(mutation, variables) + # Select the commentCreate field from the mutation response + return CreateCommentResponse(**added_comment["commentCreate"]) + except LinearAPIException as e: + raise e + + async def try_get_team_by_name(self, team_name: str) -> str: + try: + query = """ + query GetTeamId($searchTerm: String!) { + teams(filter: { + or: [ + { name: { eqIgnoreCase: $searchTerm } }, + { key: { eqIgnoreCase: $searchTerm } } + ] + }) { + nodes { + id + name + key + } + } + } + """ + + variables: dict[str, Any] = { + "searchTerm": team_name, + } + + team_id = await self.query(query, variables) + return team_id["teams"]["nodes"][0]["id"] + except LinearAPIException as e: + raise e + + async def try_create_issue( + self, + team_id: str, + title: str, + description: str | None = None, + priority: int | None = None, + project_id: str | None = None, + ) -> CreateIssueResponse: + try: + mutation = """ + mutation IssueCreate($input: IssueCreateInput!) { + issueCreate(input: $input) { + issue { + title + description + id + identifier + priority + } + } + } + """ + + variables: dict[str, Any] = { + "input": { + "teamId": team_id, + "title": title, + } + } + + if project_id: + variables["input"]["projectId"] = project_id + + if description: + variables["input"]["description"] = description + + if priority: + variables["input"]["priority"] = priority + + added_issue = await self.mutate(mutation, variables) + return CreateIssueResponse(**added_issue["issueCreate"]) + except LinearAPIException as e: + raise e + + async def try_search_projects(self, term: str) -> list[Project]: + try: + query = """ + query SearchProjects($term: String!, $includeComments: Boolean!) { + searchProjects(term: $term, includeComments: $includeComments) { + nodes { + id + name + description + priority + progress + content + } + } + } + """ + + variables: dict[str, Any] = { + "term": term, + "includeComments": True, + } + + projects = await self.query(query, variables) + return [ + Project(**project) for project in projects["searchProjects"]["nodes"] + ] + except LinearAPIException as e: + raise e + + async def try_search_issues(self, term: str) -> list[Issue]: + try: + query = """ + query SearchIssues($term: String!, $includeComments: Boolean!) { + searchIssues(term: $term, includeComments: $includeComments) { + nodes { + id + identifier + title + description + priority + } + } + } + """ + + variables: dict[str, Any] = { + "term": term, + "includeComments": True, + } + + issues = await self.query(query, variables) + return [Issue(**issue) for issue in issues["searchIssues"]["nodes"]] + except LinearAPIException as e: + raise e diff --git a/autogpt_platform/backend/backend/blocks/linear/_config.py b/autogpt_platform/backend/backend/blocks/linear/_config.py new file mode 100644 index 000000000000..c5337c481ccc --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/_config.py @@ -0,0 +1,91 @@ +""" +Shared configuration for all Linear blocks using the new SDK pattern. +""" + +from enum import Enum + +from backend.sdk import ( + APIKeyCredentials, + BlockCostType, + OAuth2Credentials, + ProviderBuilder, + SecretStr, +) + +from ._oauth import LinearOAuthHandler + +# (required) Comma separated list of scopes: + +# read - (Default) Read access for the user's account. This scope will always be present. + +# write - Write access for the user's account. If your application only needs to create comments, use a more targeted scope + +# issues:create - Allows creating new issues and their attachments + +# comments:create - Allows creating new issue comments + +# timeSchedule:write - Allows creating and modifying time schedules + + +# admin - Full access to admin level endpoints. You should never ask for this permission unless it's absolutely needed +class LinearScope(str, Enum): + READ = "read" + WRITE = "write" + ISSUES_CREATE = "issues:create" + COMMENTS_CREATE = "comments:create" + TIME_SCHEDULE_WRITE = "timeSchedule:write" + ADMIN = "admin" + + +linear = ( + ProviderBuilder("linear") + .with_api_key(env_var_name="LINEAR_API_KEY", title="Linear API Key") + .with_base_cost(1, BlockCostType.RUN) + .with_oauth( + LinearOAuthHandler, + scopes=[ + LinearScope.READ, + LinearScope.WRITE, + LinearScope.ISSUES_CREATE, + LinearScope.COMMENTS_CREATE, + ], + client_id_env_var="LINEAR_CLIENT_ID", + client_secret_env_var="LINEAR_CLIENT_SECRET", + ) + .build() +) + + +TEST_CREDENTIALS_OAUTH = OAuth2Credentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="linear", + title="Mock Linear API key", + username="mock-linear-username", + access_token=SecretStr("mock-linear-access-token"), + access_token_expires_at=None, + refresh_token=SecretStr("mock-linear-refresh-token"), + refresh_token_expires_at=None, + scopes=["mock-linear-scopes"], +) + +TEST_CREDENTIALS_API_KEY = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="linear", + title="Mock Linear API key", + api_key=SecretStr("mock-linear-api-key"), + expires_at=None, +) + +TEST_CREDENTIALS_INPUT_OAUTH = { + "provider": TEST_CREDENTIALS_OAUTH.provider, + "id": TEST_CREDENTIALS_OAUTH.id, + "type": TEST_CREDENTIALS_OAUTH.type, + "title": TEST_CREDENTIALS_OAUTH.type, +} + +TEST_CREDENTIALS_INPUT_API_KEY = { + "provider": TEST_CREDENTIALS_API_KEY.provider, + "id": TEST_CREDENTIALS_API_KEY.id, + "type": TEST_CREDENTIALS_API_KEY.type, + "title": TEST_CREDENTIALS_API_KEY.type, +} diff --git a/autogpt_platform/backend/backend/blocks/linear/_oauth.py b/autogpt_platform/backend/backend/blocks/linear/_oauth.py new file mode 100644 index 000000000000..d1eb4f6bfc38 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/_oauth.py @@ -0,0 +1,181 @@ +""" +Linear OAuth handler implementation. +""" + +import json +from typing import Optional +from urllib.parse import urlencode + +from backend.sdk import ( + APIKeyCredentials, + BaseOAuthHandler, + OAuth2Credentials, + ProviderName, + Requests, + SecretStr, +) + + +class LinearAPIException(Exception): + """Exception for Linear API errors.""" + + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.status_code = status_code + + +class LinearOAuthHandler(BaseOAuthHandler): + """ + OAuth2 handler for Linear. + """ + + # Provider name will be set dynamically by the SDK when registered + # We use a placeholder that will be replaced by AutoRegistry.register_provider() + PROVIDER_NAME = ProviderName("linear") + + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.auth_base_url = "https://linear.app/oauth/authorize" + self.token_url = "https://api.linear.app/oauth/token" # Correct token URL + self.revoke_url = "https://api.linear.app/oauth/revoke" + + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: + params = { + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "response_type": "code", # Important: include "response_type" + "scope": ",".join(scopes), # Comma-separated, not space-separated + "state": state, + } + return f"{self.auth_base_url}?{urlencode(params)}" + + async def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] + ) -> OAuth2Credentials: + return await self._request_tokens( + {"code": code, "redirect_uri": self.redirect_uri} + ) + + async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + if not credentials.access_token: + raise ValueError("No access token to revoke") + + headers = { + "Authorization": f"Bearer {credentials.access_token.get_secret_value()}" + } + + response = await Requests().post(self.revoke_url, headers=headers) + if not response.ok: + try: + error_data = response.json() + error_message = error_data.get("error", "Unknown error") + except json.JSONDecodeError: + error_message = response.text + raise LinearAPIException( + f"Failed to revoke Linear tokens ({response.status}): {error_message}", + response.status, + ) + + return True # Linear doesn't return JSON on successful revoke + + async def _refresh_tokens( + self, credentials: OAuth2Credentials + ) -> OAuth2Credentials: + if not credentials.refresh_token: + raise ValueError( + "No refresh token available." + ) # Linear uses non-expiring tokens + + return await self._request_tokens( + { + "refresh_token": credentials.refresh_token.get_secret_value(), + "grant_type": "refresh_token", + } + ) + + async def _request_tokens( + self, + params: dict[str, str], + current_credentials: Optional[OAuth2Credentials] = None, + ) -> OAuth2Credentials: + request_body = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "grant_type": "authorization_code", # Ensure grant_type is correct + **params, + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded" + } # Correct header for token request + response = await Requests().post( + self.token_url, data=request_body, headers=headers + ) + + if not response.ok: + try: + error_data = response.json() + error_message = error_data.get("error", "Unknown error") + except json.JSONDecodeError: + error_message = response.text + raise LinearAPIException( + f"Failed to fetch Linear tokens ({response.status}): {error_message}", + response.status, + ) + + token_data = response.json() + + # Note: Linear access tokens do not expire, so we set expires_at to None + new_credentials = OAuth2Credentials( + provider=self.PROVIDER_NAME, + title=current_credentials.title if current_credentials else None, + username=token_data.get("user", {}).get( + "name", "Unknown User" + ), # extract name or set appropriate + access_token=token_data["access_token"], + scopes=token_data["scope"].split( + "," + ), # Linear returns comma-separated scopes + refresh_token=token_data.get( + "refresh_token" + ), # Linear uses non-expiring tokens so this might be null + access_token_expires_at=None, + refresh_token_expires_at=None, + ) + if current_credentials: + new_credentials.id = current_credentials.id + return new_credentials + + async def _request_username(self, access_token: str) -> Optional[str]: + # Use the LinearClient to fetch user details using GraphQL + from ._api import LinearClient + + try: + # Create a temporary OAuth2Credentials object for the LinearClient + linear_client = LinearClient( + APIKeyCredentials( + api_key=SecretStr(access_token), + title="temp", + provider=self.PROVIDER_NAME, + expires_at=None, + ) + ) # Temporary credentials for this request + + query = """ + query Viewer { + viewer { + name + } + } + """ + + response = await linear_client.query(query) + return response["viewer"]["name"] + + except Exception as e: # Handle any errors + print(f"Error fetching username: {e}") + return None diff --git a/autogpt_platform/backend/backend/blocks/linear/comment.py b/autogpt_platform/backend/backend/blocks/linear/comment.py new file mode 100644 index 000000000000..17cd54c212e0 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/comment.py @@ -0,0 +1,93 @@ +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + OAuth2Credentials, + SchemaField, +) + +from ._api import LinearAPIException, LinearClient +from ._config import ( + TEST_CREDENTIALS_INPUT_OAUTH, + TEST_CREDENTIALS_OAUTH, + LinearScope, + linear, +) +from .models import CreateCommentResponse + + +class LinearCreateCommentBlock(Block): + """Block for creating comments on Linear issues""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = linear.credentials_field( + description="Linear credentials with comment creation permissions", + required_scopes={LinearScope.COMMENTS_CREATE}, + ) + issue_id: str = SchemaField(description="ID of the issue to comment on") + comment: str = SchemaField(description="Comment text to add to the issue") + + class Output(BlockSchema): + comment_id: str = SchemaField(description="ID of the created comment") + comment_body: str = SchemaField( + description="Text content of the created comment" + ) + error: str = SchemaField(description="Error message if comment creation failed") + + def __init__(self): + super().__init__( + id="8f7d3a2e-9b5c-4c6a-8f1d-7c8b3e4a5d6c", + description="Creates a new comment on a Linear issue", + input_schema=self.Input, + output_schema=self.Output, + categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING}, + test_input={ + "issue_id": "TEST-123", + "comment": "Test comment", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[("comment_id", "abc123"), ("comment_body", "Test comment")], + test_mock={ + "create_comment": lambda *args, **kwargs: ( + "abc123", + "Test comment", + ) + }, + ) + + @staticmethod + async def create_comment( + credentials: OAuth2Credentials | APIKeyCredentials, issue_id: str, comment: str + ) -> tuple[str, str]: + client = LinearClient(credentials=credentials) + response: CreateCommentResponse = await client.try_create_comment( + issue_id=issue_id, comment=comment + ) + return response.comment.id, response.comment.body + + async def run( + self, + input_data: Input, + *, + credentials: OAuth2Credentials | APIKeyCredentials, + **kwargs, + ) -> BlockOutput: + """Execute the comment creation""" + try: + comment_id, comment_body = await self.create_comment( + credentials=credentials, + issue_id=input_data.issue_id, + comment=input_data.comment, + ) + + yield "comment_id", comment_id + yield "comment_body", comment_body + + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/linear/issues.py b/autogpt_platform/backend/backend/blocks/linear/issues.py new file mode 100644 index 000000000000..cd0fa0e98abf --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/issues.py @@ -0,0 +1,205 @@ +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + OAuth2Credentials, + SchemaField, +) + +from ._api import LinearAPIException, LinearClient +from ._config import ( + TEST_CREDENTIALS_INPUT_OAUTH, + TEST_CREDENTIALS_OAUTH, + LinearScope, + linear, +) +from .models import CreateIssueResponse, Issue + + +class LinearCreateIssueBlock(Block): + """Block for creating issues on Linear""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = linear.credentials_field( + description="Linear credentials with issue creation permissions", + required_scopes={LinearScope.ISSUES_CREATE}, + ) + title: str = SchemaField(description="Title of the issue") + description: str | None = SchemaField(description="Description of the issue") + team_name: str = SchemaField( + description="Name of the team to create the issue on" + ) + priority: int | None = SchemaField( + description="Priority of the issue", + default=None, + ge=0, + le=4, + ) + project_name: str | None = SchemaField( + description="Name of the project to create the issue on", + default=None, + ) + + class Output(BlockSchema): + issue_id: str = SchemaField(description="ID of the created issue") + issue_title: str = SchemaField(description="Title of the created issue") + error: str = SchemaField(description="Error message if issue creation failed") + + def __init__(self): + super().__init__( + id="f9c68f55-dcca-40a8-8771-abf9601680aa", + description="Creates a new issue on Linear", + input_schema=self.Input, + output_schema=self.Output, + categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING}, + test_input={ + "title": "Test issue", + "description": "Test description", + "team_name": "Test team", + "project_name": "Test project", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[("issue_id", "abc123"), ("issue_title", "Test issue")], + test_mock={ + "create_issue": lambda *args, **kwargs: ( + "abc123", + "Test issue", + ) + }, + ) + + @staticmethod + async def create_issue( + credentials: OAuth2Credentials | APIKeyCredentials, + team_name: str, + title: str, + description: str | None = None, + priority: int | None = None, + project_name: str | None = None, + ) -> tuple[str, str]: + client = LinearClient(credentials=credentials) + team_id = await client.try_get_team_by_name(team_name=team_name) + project_id: str | None = None + if project_name: + projects = await client.try_search_projects(term=project_name) + if projects: + project_id = projects[0].id + else: + raise LinearAPIException("Project not found", status_code=404) + response: CreateIssueResponse = await client.try_create_issue( + team_id=team_id, + title=title, + description=description, + priority=priority, + project_id=project_id, + ) + return response.issue.identifier, response.issue.title + + async def run( + self, + input_data: Input, + *, + credentials: OAuth2Credentials, + **kwargs, + ) -> BlockOutput: + """Execute the issue creation""" + try: + issue_id, issue_title = await self.create_issue( + credentials=credentials, + team_name=input_data.team_name, + title=input_data.title, + description=input_data.description, + priority=input_data.priority, + project_name=input_data.project_name, + ) + + yield "issue_id", issue_id + yield "issue_title", issue_title + + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" + + +class LinearSearchIssuesBlock(Block): + """Block for searching issues on Linear""" + + class Input(BlockSchema): + term: str = SchemaField(description="Term to search for issues") + credentials: CredentialsMetaInput = linear.credentials_field( + description="Linear credentials with read permissions", + required_scopes={LinearScope.READ}, + ) + + class Output(BlockSchema): + issues: list[Issue] = SchemaField(description="List of issues") + + def __init__(self): + super().__init__( + id="b5a2a0e6-26b4-4c5b-8a42-bc79e9cb65c2", + description="Searches for issues on Linear", + input_schema=self.Input, + output_schema=self.Output, + test_input={ + "term": "Test issue", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[ + ( + "issues", + [ + Issue( + id="abc123", + identifier="abc123", + title="Test issue", + description="Test description", + priority=1, + ) + ], + ) + ], + test_mock={ + "search_issues": lambda *args, **kwargs: [ + Issue( + id="abc123", + identifier="abc123", + title="Test issue", + description="Test description", + priority=1, + ) + ] + }, + ) + + @staticmethod + async def search_issues( + credentials: OAuth2Credentials | APIKeyCredentials, + term: str, + ) -> list[Issue]: + client = LinearClient(credentials=credentials) + response: list[Issue] = await client.try_search_issues(term=term) + return response + + async def run( + self, + input_data: Input, + *, + credentials: OAuth2Credentials | APIKeyCredentials, + **kwargs, + ) -> BlockOutput: + """Execute the issue search""" + try: + issues = await self.search_issues( + credentials=credentials, term=input_data.term + ) + yield "issues", issues + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/linear/models.py b/autogpt_platform/backend/backend/blocks/linear/models.py new file mode 100644 index 000000000000..00fa535d694d --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/models.py @@ -0,0 +1,41 @@ +from backend.sdk import BaseModel + + +class Comment(BaseModel): + id: str + body: str + + +class CreateCommentInput(BaseModel): + body: str + issueId: str + + +class CreateCommentResponse(BaseModel): + success: bool + comment: Comment + + +class CreateCommentResponseWrapper(BaseModel): + commentCreate: CreateCommentResponse + + +class Issue(BaseModel): + id: str + identifier: str + title: str + description: str | None + priority: int + + +class CreateIssueResponse(BaseModel): + issue: Issue + + +class Project(BaseModel): + id: str + name: str + description: str + priority: int + progress: int + content: str diff --git a/autogpt_platform/backend/backend/blocks/linear/projects.py b/autogpt_platform/backend/backend/blocks/linear/projects.py new file mode 100644 index 000000000000..4eeb1ed99d30 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/linear/projects.py @@ -0,0 +1,105 @@ +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + OAuth2Credentials, + SchemaField, +) + +from ._api import LinearAPIException, LinearClient +from ._config import ( + TEST_CREDENTIALS_INPUT_OAUTH, + TEST_CREDENTIALS_OAUTH, + LinearScope, + linear, +) +from .models import Project + + +class LinearSearchProjectsBlock(Block): + """Block for searching projects on Linear""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = linear.credentials_field( + description="Linear credentials with read permissions", + required_scopes={LinearScope.READ}, + ) + term: str = SchemaField(description="Term to search for projects") + + class Output(BlockSchema): + projects: list[Project] = SchemaField(description="List of projects") + error: str = SchemaField(description="Error message if issue creation failed") + + def __init__(self): + super().__init__( + id="446a1d35-9d8f-4ac5-83ea-7684ec50e6af", + description="Searches for projects on Linear", + input_schema=self.Input, + output_schema=self.Output, + categories={BlockCategory.PRODUCTIVITY, BlockCategory.ISSUE_TRACKING}, + test_input={ + "term": "Test project", + "credentials": TEST_CREDENTIALS_INPUT_OAUTH, + }, + test_credentials=TEST_CREDENTIALS_OAUTH, + test_output=[ + ( + "projects", + [ + Project( + id="abc123", + name="Test project", + description="Test description", + priority=1, + progress=1, + content="Test content", + ) + ], + ) + ], + test_mock={ + "search_projects": lambda *args, **kwargs: [ + Project( + id="abc123", + name="Test project", + description="Test description", + priority=1, + progress=1, + content="Test content", + ) + ] + }, + ) + + @staticmethod + async def search_projects( + credentials: OAuth2Credentials | APIKeyCredentials, + term: str, + ) -> list[Project]: + client = LinearClient(credentials=credentials) + response: list[Project] = await client.try_search_projects(term=term) + return response + + async def run( + self, + input_data: Input, + *, + credentials: OAuth2Credentials | APIKeyCredentials, + **kwargs, + ) -> BlockOutput: + """Execute the project search""" + try: + projects = await self.search_projects( + credentials=credentials, + term=input_data.term, + ) + + yield "projects", projects + + except LinearAPIException as e: + yield "error", str(e) + except Exception as e: + yield "error", f"Unexpected error: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/llm.py b/autogpt_platform/backend/backend/blocks/llm.py index 0e56abed52e3..b20df4052ef4 100644 --- a/autogpt_platform/backend/backend/blocks/llm.py +++ b/autogpt_platform/backend/backend/blocks/llm.py @@ -1,45 +1,48 @@ import ast import logging +from abc import ABC from enum import Enum, EnumMeta from json import JSONDecodeError -from types import MappingProxyType -from typing import TYPE_CHECKING, Any, List, Literal, NamedTuple - -from pydantic import SecretStr - -from backend.integrations.providers import ProviderName - -if TYPE_CHECKING: - from enum import _EnumMemberT +from typing import Any, Iterable, List, Literal, NamedTuple, Optional import anthropic import ollama import openai -from groq import Groq +from anthropic.types import ToolParam +from groq import AsyncGroq +from pydantic import BaseModel, SecretStr from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import ( APIKeyCredentials, CredentialsField, CredentialsMetaInput, + NodeExecutionStats, SchemaField, ) +from backend.integrations.providers import ProviderName from backend.util import json -from backend.util.settings import BehaveAs, Settings +from backend.util.logging import TruncatedLogger +from backend.util.prompt import compress_prompt, estimate_token_count +from backend.util.text import TextFormatter -logger = logging.getLogger(__name__) +logger = TruncatedLogger(logging.getLogger(__name__), "[LLM-Block]") +fmt = TextFormatter() LLMProviderName = Literal[ + ProviderName.AIML_API, ProviderName.ANTHROPIC, ProviderName.GROQ, ProviderName.OLLAMA, ProviderName.OPENAI, ProviderName.OPEN_ROUTER, + ProviderName.LLAMA_API, + ProviderName.V0, ] AICredentials = CredentialsMetaInput[LLMProviderName, Literal["api_key"]] TEST_CREDENTIALS = APIKeyCredentials( - id="ed55ac19-356e-4243-a6cb-bc599e9b716f", + id="769f6af7-820b-4d5d-9b7a-ab82bbc165f", provider="openai", api_key=SecretStr("mock-openai-api-key"), title="Mock OpenAI API key", @@ -66,64 +69,75 @@ def AICredentialsField() -> AICredentials: class ModelMetadata(NamedTuple): provider: str context_window: int + max_output_tokens: int | None class LlmModelMeta(EnumMeta): - @property - def __members__( - self: type["_EnumMemberT"], - ) -> MappingProxyType[str, "_EnumMemberT"]: - if Settings().config.behave_as == BehaveAs.LOCAL: - members = super().__members__ - return members - else: - removed_providers = ["ollama"] - existing_members = super().__members__ - members = { - name: member - for name, member in existing_members.items() - if LlmModel[name].provider not in removed_providers - } - return MappingProxyType(members) + pass class LlmModel(str, Enum, metaclass=LlmModelMeta): # OpenAI models - O1_PREVIEW = "o1-preview" + O3_MINI = "o3-mini" + O3 = "o3-2025-04-16" + O1 = "o1" O1_MINI = "o1-mini" + # GPT-5 models + GPT5 = "gpt-5-2025-08-07" + GPT5_MINI = "gpt-5-mini-2025-08-07" + GPT5_NANO = "gpt-5-nano-2025-08-07" + GPT5_CHAT = "gpt-5-chat-latest" + GPT41 = "gpt-4.1-2025-04-14" + GPT41_MINI = "gpt-4.1-mini-2025-04-14" GPT4O_MINI = "gpt-4o-mini" GPT4O = "gpt-4o" GPT4_TURBO = "gpt-4-turbo" GPT3_5_TURBO = "gpt-3.5-turbo" # Anthropic models + CLAUDE_4_1_OPUS = "claude-opus-4-1-20250805" + CLAUDE_4_OPUS = "claude-opus-4-20250514" + CLAUDE_4_SONNET = "claude-sonnet-4-20250514" + CLAUDE_3_7_SONNET = "claude-3-7-sonnet-20250219" CLAUDE_3_5_SONNET = "claude-3-5-sonnet-latest" + CLAUDE_3_5_HAIKU = "claude-3-5-haiku-latest" CLAUDE_3_HAIKU = "claude-3-haiku-20240307" + # AI/ML API models + AIML_API_QWEN2_5_72B = "Qwen/Qwen2.5-72B-Instruct-Turbo" + AIML_API_LLAMA3_1_70B = "nvidia/llama-3.1-nemotron-70b-instruct" + AIML_API_LLAMA3_3_70B = "meta-llama/Llama-3.3-70B-Instruct-Turbo" + AIML_API_META_LLAMA_3_1_70B = "meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo" + AIML_API_LLAMA_3_2_3B = "meta-llama/Llama-3.2-3B-Instruct-Turbo" # Groq models - LLAMA3_8B = "llama3-8b-8192" - LLAMA3_70B = "llama3-70b-8192" - MIXTRAL_8X7B = "mixtral-8x7b-32768" - GEMMA_7B = "gemma-7b-it" GEMMA2_9B = "gemma2-9b-it" - # New Groq models (Preview) - LLAMA3_1_405B = "llama-3.1-405b-reasoning" - LLAMA3_1_70B = "llama-3.1-70b-versatile" + LLAMA3_3_70B = "llama-3.3-70b-versatile" LLAMA3_1_8B = "llama-3.1-8b-instant" + LLAMA3_70B = "llama3-70b-8192" + LLAMA3_8B = "llama3-8b-8192" + # Groq preview models + DEEPSEEK_LLAMA_70B = "deepseek-r1-distill-llama-70b" # Ollama models + OLLAMA_LLAMA3_3 = "llama3.3" + OLLAMA_LLAMA3_2 = "llama3.2" OLLAMA_LLAMA3_8B = "llama3" OLLAMA_LLAMA3_405B = "llama3.1:405b" OLLAMA_DOLPHIN = "dolphin-mistral:latest" # OpenRouter models - GEMINI_FLASH_1_5_8B = "google/gemini-flash-1.5" - GROK_BETA = "x-ai/grok-beta" + OPENAI_GPT_OSS_120B = "openai/gpt-oss-120b" + OPENAI_GPT_OSS_20B = "openai/gpt-oss-20b" + GEMINI_FLASH_1_5 = "google/gemini-flash-1.5" + GEMINI_2_5_PRO = "google/gemini-2.5-pro-preview-03-25" + GEMINI_2_5_FLASH = "google/gemini-2.5-flash" + GEMINI_2_0_FLASH = "google/gemini-2.0-flash-001" + GEMINI_2_5_FLASH_LITE_PREVIEW = "google/gemini-2.5-flash-lite-preview-06-17" + GEMINI_2_0_FLASH_LITE = "google/gemini-2.0-flash-lite-001" MISTRAL_NEMO = "mistralai/mistral-nemo" COHERE_COMMAND_R_08_2024 = "cohere/command-r-08-2024" COHERE_COMMAND_R_PLUS_08_2024 = "cohere/command-r-plus-08-2024" - EVA_QWEN_2_5_32B = "eva-unit-01/eva-qwen-2.5-32b" - DEEPSEEK_CHAT = "deepseek/deepseek-chat" - PERPLEXITY_LLAMA_3_1_SONAR_LARGE_128K_ONLINE = ( - "perplexity/llama-3.1-sonar-large-128k-online" - ) - QWEN_QWQ_32B_PREVIEW = "qwen/qwq-32b-preview" + DEEPSEEK_CHAT = "deepseek/deepseek-chat" # Actually: DeepSeek V3 + DEEPSEEK_R1_0528 = "deepseek/deepseek-r1-0528" + PERPLEXITY_SONAR = "perplexity/sonar" + PERPLEXITY_SONAR_PRO = "perplexity/sonar-pro" + PERPLEXITY_SONAR_DEEP_RESEARCH = "perplexity/sonar-deep-research" NOUSRESEARCH_HERMES_3_LLAMA_3_1_405B = "nousresearch/hermes-3-llama-3.1-405b" NOUSRESEARCH_HERMES_3_LLAMA_3_1_70B = "nousresearch/hermes-3-llama-3.1-70b" AMAZON_NOVA_LITE_V1 = "amazon/nova-lite-v1" @@ -131,6 +145,21 @@ class LlmModel(str, Enum, metaclass=LlmModelMeta): AMAZON_NOVA_PRO_V1 = "amazon/nova-pro-v1" MICROSOFT_WIZARDLM_2_8X22B = "microsoft/wizardlm-2-8x22b" GRYPHE_MYTHOMAX_L2_13B = "gryphe/mythomax-l2-13b" + META_LLAMA_4_SCOUT = "meta-llama/llama-4-scout" + META_LLAMA_4_MAVERICK = "meta-llama/llama-4-maverick" + GROK_4 = "x-ai/grok-4" + KIMI_K2 = "moonshotai/kimi-k2" + QWEN3_235B_A22B_THINKING = "qwen/qwen3-235b-a22b-thinking-2507" + QWEN3_CODER = "qwen/qwen3-coder" + # Llama API models + LLAMA_API_LLAMA_4_SCOUT = "Llama-4-Scout-17B-16E-Instruct-FP8" + LLAMA_API_LLAMA4_MAVERICK = "Llama-4-Maverick-17B-128E-Instruct-FP8" + LLAMA_API_LLAMA3_3_8B = "Llama-3.3-8B-Instruct" + LLAMA_API_LLAMA3_3_70B = "Llama-3.3-70B-Instruct" + # v0 by Vercel models + V0_1_5_MD = "v0-1.5-md" + V0_1_5_LG = "v0-1.5-lg" + V0_1_0_MD = "v0-1.0-md" @property def metadata(self) -> ModelMetadata: @@ -144,46 +173,122 @@ def provider(self) -> str: def context_window(self) -> int: return self.metadata.context_window + @property + def max_output_tokens(self) -> int | None: + return self.metadata.max_output_tokens + MODEL_METADATA = { - LlmModel.O1_PREVIEW: ModelMetadata("openai", 32000), - LlmModel.O1_MINI: ModelMetadata("openai", 62000), - LlmModel.GPT4O_MINI: ModelMetadata("openai", 128000), - LlmModel.GPT4O: ModelMetadata("openai", 128000), - LlmModel.GPT4_TURBO: ModelMetadata("openai", 128000), - LlmModel.GPT3_5_TURBO: ModelMetadata("openai", 16385), - LlmModel.CLAUDE_3_5_SONNET: ModelMetadata("anthropic", 200000), - LlmModel.CLAUDE_3_HAIKU: ModelMetadata("anthropic", 200000), - LlmModel.LLAMA3_8B: ModelMetadata("groq", 8192), - LlmModel.LLAMA3_70B: ModelMetadata("groq", 8192), - LlmModel.MIXTRAL_8X7B: ModelMetadata("groq", 32768), - LlmModel.GEMMA_7B: ModelMetadata("groq", 8192), - LlmModel.GEMMA2_9B: ModelMetadata("groq", 8192), - LlmModel.LLAMA3_1_405B: ModelMetadata("groq", 8192), - # Limited to 16k during preview - LlmModel.LLAMA3_1_70B: ModelMetadata("groq", 131072), - LlmModel.LLAMA3_1_8B: ModelMetadata("groq", 131072), - LlmModel.OLLAMA_LLAMA3_8B: ModelMetadata("ollama", 8192), - LlmModel.OLLAMA_LLAMA3_405B: ModelMetadata("ollama", 8192), - LlmModel.OLLAMA_DOLPHIN: ModelMetadata("ollama", 32768), - LlmModel.GEMINI_FLASH_1_5_8B: ModelMetadata("open_router", 8192), - LlmModel.GROK_BETA: ModelMetadata("open_router", 8192), - LlmModel.MISTRAL_NEMO: ModelMetadata("open_router", 4000), - LlmModel.COHERE_COMMAND_R_08_2024: ModelMetadata("open_router", 4000), - LlmModel.COHERE_COMMAND_R_PLUS_08_2024: ModelMetadata("open_router", 4000), - LlmModel.EVA_QWEN_2_5_32B: ModelMetadata("open_router", 4000), - LlmModel.DEEPSEEK_CHAT: ModelMetadata("open_router", 8192), - LlmModel.PERPLEXITY_LLAMA_3_1_SONAR_LARGE_128K_ONLINE: ModelMetadata( - "open_router", 8192 + # https://platform.openai.com/docs/models + LlmModel.O3: ModelMetadata("openai", 200000, 100000), + LlmModel.O3_MINI: ModelMetadata("openai", 200000, 100000), # o3-mini-2025-01-31 + LlmModel.O1: ModelMetadata("openai", 200000, 100000), # o1-2024-12-17 + LlmModel.O1_MINI: ModelMetadata("openai", 128000, 65536), # o1-mini-2024-09-12 + # GPT-5 models + LlmModel.GPT5: ModelMetadata("openai", 400000, 128000), + LlmModel.GPT5_MINI: ModelMetadata("openai", 400000, 128000), + LlmModel.GPT5_NANO: ModelMetadata("openai", 400000, 128000), + LlmModel.GPT5_CHAT: ModelMetadata("openai", 400000, 16384), + LlmModel.GPT41: ModelMetadata("openai", 1047576, 32768), + LlmModel.GPT41_MINI: ModelMetadata("openai", 1047576, 32768), + LlmModel.GPT4O_MINI: ModelMetadata( + "openai", 128000, 16384 + ), # gpt-4o-mini-2024-07-18 + LlmModel.GPT4O: ModelMetadata("openai", 128000, 16384), # gpt-4o-2024-08-06 + LlmModel.GPT4_TURBO: ModelMetadata( + "openai", 128000, 4096 + ), # gpt-4-turbo-2024-04-09 + LlmModel.GPT3_5_TURBO: ModelMetadata("openai", 16385, 4096), # gpt-3.5-turbo-0125 + # https://docs.anthropic.com/en/docs/about-claude/models + LlmModel.CLAUDE_4_1_OPUS: ModelMetadata( + "anthropic", 200000, 32000 + ), # claude-opus-4-1-20250805 + LlmModel.CLAUDE_4_OPUS: ModelMetadata( + "anthropic", 200000, 8192 + ), # claude-4-opus-20250514 + LlmModel.CLAUDE_4_SONNET: ModelMetadata( + "anthropic", 200000, 8192 + ), # claude-4-sonnet-20250514 + LlmModel.CLAUDE_3_7_SONNET: ModelMetadata( + "anthropic", 200000, 8192 + ), # claude-3-7-sonnet-20250219 + LlmModel.CLAUDE_3_5_SONNET: ModelMetadata( + "anthropic", 200000, 8192 + ), # claude-3-5-sonnet-20241022 + LlmModel.CLAUDE_3_5_HAIKU: ModelMetadata( + "anthropic", 200000, 8192 + ), # claude-3-5-haiku-20241022 + LlmModel.CLAUDE_3_HAIKU: ModelMetadata( + "anthropic", 200000, 4096 + ), # claude-3-haiku-20240307 + # https://docs.aimlapi.com/api-overview/model-database/text-models + LlmModel.AIML_API_QWEN2_5_72B: ModelMetadata("aiml_api", 32000, 8000), + LlmModel.AIML_API_LLAMA3_1_70B: ModelMetadata("aiml_api", 128000, 40000), + LlmModel.AIML_API_LLAMA3_3_70B: ModelMetadata("aiml_api", 128000, None), + LlmModel.AIML_API_META_LLAMA_3_1_70B: ModelMetadata("aiml_api", 131000, 2000), + LlmModel.AIML_API_LLAMA_3_2_3B: ModelMetadata("aiml_api", 128000, None), + # https://console.groq.com/docs/models + LlmModel.GEMMA2_9B: ModelMetadata("groq", 8192, None), + LlmModel.LLAMA3_3_70B: ModelMetadata("groq", 128000, 32768), + LlmModel.LLAMA3_1_8B: ModelMetadata("groq", 128000, 8192), + LlmModel.LLAMA3_70B: ModelMetadata("groq", 8192, None), + LlmModel.LLAMA3_8B: ModelMetadata("groq", 8192, None), + LlmModel.DEEPSEEK_LLAMA_70B: ModelMetadata("groq", 128000, None), + # https://ollama.com/library + LlmModel.OLLAMA_LLAMA3_3: ModelMetadata("ollama", 8192, None), + LlmModel.OLLAMA_LLAMA3_2: ModelMetadata("ollama", 8192, None), + LlmModel.OLLAMA_LLAMA3_8B: ModelMetadata("ollama", 8192, None), + LlmModel.OLLAMA_LLAMA3_405B: ModelMetadata("ollama", 8192, None), + LlmModel.OLLAMA_DOLPHIN: ModelMetadata("ollama", 32768, None), + # https://openrouter.ai/models + LlmModel.GEMINI_FLASH_1_5: ModelMetadata("open_router", 1000000, 8192), + LlmModel.GEMINI_2_5_PRO: ModelMetadata("open_router", 1050000, 8192), + LlmModel.GEMINI_2_5_FLASH: ModelMetadata("open_router", 1048576, 65535), + LlmModel.GEMINI_2_0_FLASH: ModelMetadata("open_router", 1048576, 8192), + LlmModel.GEMINI_2_5_FLASH_LITE_PREVIEW: ModelMetadata( + "open_router", 1048576, 65535 + ), + LlmModel.GEMINI_2_0_FLASH_LITE: ModelMetadata("open_router", 1048576, 8192), + LlmModel.MISTRAL_NEMO: ModelMetadata("open_router", 128000, 4096), + LlmModel.COHERE_COMMAND_R_08_2024: ModelMetadata("open_router", 128000, 4096), + LlmModel.COHERE_COMMAND_R_PLUS_08_2024: ModelMetadata("open_router", 128000, 4096), + LlmModel.DEEPSEEK_CHAT: ModelMetadata("open_router", 64000, 2048), + LlmModel.DEEPSEEK_R1_0528: ModelMetadata("open_router", 163840, 163840), + LlmModel.PERPLEXITY_SONAR: ModelMetadata("open_router", 127000, 127000), + LlmModel.PERPLEXITY_SONAR_PRO: ModelMetadata("open_router", 200000, 8000), + LlmModel.PERPLEXITY_SONAR_DEEP_RESEARCH: ModelMetadata( + "open_router", + 128000, + 128000, ), - LlmModel.QWEN_QWQ_32B_PREVIEW: ModelMetadata("open_router", 4000), - LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_405B: ModelMetadata("open_router", 4000), - LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_70B: ModelMetadata("open_router", 4000), - LlmModel.AMAZON_NOVA_LITE_V1: ModelMetadata("open_router", 4000), - LlmModel.AMAZON_NOVA_MICRO_V1: ModelMetadata("open_router", 4000), - LlmModel.AMAZON_NOVA_PRO_V1: ModelMetadata("open_router", 4000), - LlmModel.MICROSOFT_WIZARDLM_2_8X22B: ModelMetadata("open_router", 4000), - LlmModel.GRYPHE_MYTHOMAX_L2_13B: ModelMetadata("open_router", 4000), + LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_405B: ModelMetadata( + "open_router", 131000, 4096 + ), + LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_70B: ModelMetadata( + "open_router", 12288, 12288 + ), + LlmModel.OPENAI_GPT_OSS_120B: ModelMetadata("open_router", 131072, 131072), + LlmModel.OPENAI_GPT_OSS_20B: ModelMetadata("open_router", 131072, 32768), + LlmModel.AMAZON_NOVA_LITE_V1: ModelMetadata("open_router", 300000, 5120), + LlmModel.AMAZON_NOVA_MICRO_V1: ModelMetadata("open_router", 128000, 5120), + LlmModel.AMAZON_NOVA_PRO_V1: ModelMetadata("open_router", 300000, 5120), + LlmModel.MICROSOFT_WIZARDLM_2_8X22B: ModelMetadata("open_router", 65536, 4096), + LlmModel.GRYPHE_MYTHOMAX_L2_13B: ModelMetadata("open_router", 4096, 4096), + LlmModel.META_LLAMA_4_SCOUT: ModelMetadata("open_router", 131072, 131072), + LlmModel.META_LLAMA_4_MAVERICK: ModelMetadata("open_router", 1048576, 1000000), + LlmModel.GROK_4: ModelMetadata("open_router", 256000, 256000), + LlmModel.KIMI_K2: ModelMetadata("open_router", 131000, 131000), + LlmModel.QWEN3_235B_A22B_THINKING: ModelMetadata("open_router", 262144, 262144), + LlmModel.QWEN3_CODER: ModelMetadata("open_router", 262144, 262144), + # Llama API models + LlmModel.LLAMA_API_LLAMA_4_SCOUT: ModelMetadata("llama_api", 128000, 4028), + LlmModel.LLAMA_API_LLAMA4_MAVERICK: ModelMetadata("llama_api", 128000, 4028), + LlmModel.LLAMA_API_LLAMA3_3_8B: ModelMetadata("llama_api", 128000, 4028), + LlmModel.LLAMA_API_LLAMA3_3_70B: ModelMetadata("llama_api", 128000, 4028), + # v0 by Vercel models + LlmModel.V0_1_5_MD: ModelMetadata("v0", 128000, 64000), + LlmModel.V0_1_5_LG: ModelMetadata("v0", 512000, 64000), + LlmModel.V0_1_0_MD: ModelMetadata("v0", 128000, 64000), } for model in LlmModel: @@ -191,18 +296,470 @@ def context_window(self) -> int: raise ValueError(f"Missing MODEL_METADATA metadata for model: {model}") -class MessageRole(str, Enum): - SYSTEM = "system" - USER = "user" - ASSISTANT = "assistant" +class ToolCall(BaseModel): + name: str + arguments: str + + +class ToolContentBlock(BaseModel): + id: str + type: str + function: ToolCall + + +class LLMResponse(BaseModel): + raw_response: Any + prompt: List[Any] + response: str + tool_calls: Optional[List[ToolContentBlock]] | None + prompt_tokens: int + completion_tokens: int + reasoning: Optional[str] = None + + +def convert_openai_tool_fmt_to_anthropic( + openai_tools: list[dict] | None = None, +) -> Iterable[ToolParam] | anthropic.NotGiven: + """ + Convert OpenAI tool format to Anthropic tool format. + """ + if not openai_tools or len(openai_tools) == 0: + return anthropic.NOT_GIVEN + + anthropic_tools = [] + for tool in openai_tools: + if "function" in tool: + # Handle case where tool is already in OpenAI format with "type" and "function" + function_data = tool["function"] + else: + # Handle case where tool is just the function definition + function_data = tool + + anthropic_tool: anthropic.types.ToolParam = { + "name": function_data["name"], + "description": function_data.get("description", ""), + "input_schema": { + "type": "object", + "properties": function_data.get("parameters", {}).get("properties", {}), + "required": function_data.get("parameters", {}).get("required", []), + }, + } + anthropic_tools.append(anthropic_tool) + + return anthropic_tools + + +def extract_openai_reasoning(response) -> str | None: + """Extract reasoning from OpenAI-compatible response if available.""" + """Note: This will likely not working since the reasoning is not present in another Response API""" + reasoning = None + choice = response.choices[0] + if hasattr(choice, "reasoning") and getattr(choice, "reasoning", None): + reasoning = str(getattr(choice, "reasoning")) + elif hasattr(response, "reasoning") and getattr(response, "reasoning", None): + reasoning = str(getattr(response, "reasoning")) + elif hasattr(choice.message, "reasoning") and getattr( + choice.message, "reasoning", None + ): + reasoning = str(getattr(choice.message, "reasoning")) + return reasoning + + +def extract_openai_tool_calls(response) -> list[ToolContentBlock] | None: + """Extract tool calls from OpenAI-compatible response.""" + if response.choices[0].message.tool_calls: + return [ + ToolContentBlock( + id=tool.id, + type=tool.type, + function=ToolCall( + name=tool.function.name, + arguments=tool.function.arguments, + ), + ) + for tool in response.choices[0].message.tool_calls + ] + return None + + +def get_parallel_tool_calls_param(llm_model: LlmModel, parallel_tool_calls): + """Get the appropriate parallel_tool_calls parameter for OpenAI-compatible APIs.""" + if llm_model.startswith("o") or parallel_tool_calls is None: + return openai.NOT_GIVEN + return parallel_tool_calls + + +async def llm_call( + credentials: APIKeyCredentials, + llm_model: LlmModel, + prompt: list[dict], + json_format: bool, + max_tokens: int | None, + tools: list[dict] | None = None, + ollama_host: str = "localhost:11434", + parallel_tool_calls=None, + compress_prompt_to_fit: bool = True, +) -> LLMResponse: + """ + Make a call to a language model. + + Args: + credentials: The API key credentials to use. + llm_model: The LLM model to use. + prompt: The prompt to send to the LLM. + json_format: Whether the response should be in JSON format. + max_tokens: The maximum number of tokens to generate in the chat completion. + tools: The tools to use in the chat completion. + ollama_host: The host for ollama to use. + + Returns: + LLMResponse object containing: + - prompt: The prompt sent to the LLM. + - response: The text response from the LLM. + - tool_calls: Any tool calls the model made, if applicable. + - prompt_tokens: The number of tokens used in the prompt. + - completion_tokens: The number of tokens used in the completion. + """ + provider = llm_model.metadata.provider + context_window = llm_model.context_window + + if compress_prompt_to_fit: + prompt = compress_prompt( + messages=prompt, + target_tokens=llm_model.context_window // 2, + lossy_ok=True, + ) + + # Calculate available tokens based on context window and input length + estimated_input_tokens = estimate_token_count(prompt) + model_max_output = llm_model.max_output_tokens or int(2**15) + user_max = max_tokens or model_max_output + available_tokens = max(context_window - estimated_input_tokens, 0) + max_tokens = max(min(available_tokens, model_max_output, user_max), 1) + + if provider == "openai": + tools_param = tools if tools else openai.NOT_GIVEN + oai_client = openai.AsyncOpenAI(api_key=credentials.api_key.get_secret_value()) + response_format = None + + parallel_tool_calls = get_parallel_tool_calls_param( + llm_model, parallel_tool_calls + ) + + if json_format: + response_format = {"type": "json_object"} + + response = await oai_client.chat.completions.create( + model=llm_model.value, + messages=prompt, # type: ignore + response_format=response_format, # type: ignore + max_completion_tokens=max_tokens, + tools=tools_param, # type: ignore + parallel_tool_calls=parallel_tool_calls, + ) + + tool_calls = extract_openai_tool_calls(response) + reasoning = extract_openai_reasoning(response) + + return LLMResponse( + raw_response=response.choices[0].message, + prompt=prompt, + response=response.choices[0].message.content or "", + tool_calls=tool_calls, + prompt_tokens=response.usage.prompt_tokens if response.usage else 0, + completion_tokens=response.usage.completion_tokens if response.usage else 0, + reasoning=reasoning, + ) + elif provider == "anthropic": + + an_tools = convert_openai_tool_fmt_to_anthropic(tools) + + system_messages = [p["content"] for p in prompt if p["role"] == "system"] + sysprompt = " ".join(system_messages) + + messages = [] + last_role = None + for p in prompt: + if p["role"] in ["user", "assistant"]: + if ( + p["role"] == last_role + and isinstance(messages[-1]["content"], str) + and isinstance(p["content"], str) + ): + # If the role is the same as the last one, combine the content + messages[-1]["content"] += p["content"] + else: + messages.append({"role": p["role"], "content": p["content"]}) + last_role = p["role"] + + client = anthropic.AsyncAnthropic( + api_key=credentials.api_key.get_secret_value() + ) + try: + resp = await client.messages.create( + model=llm_model.value, + system=sysprompt, + messages=messages, + max_tokens=max_tokens, + tools=an_tools, + timeout=600, + ) + + if not resp.content: + raise ValueError("No content returned from Anthropic.") + + tool_calls = None + for content_block in resp.content: + # Antropic is different to openai, need to iterate through + # the content blocks to find the tool calls + if content_block.type == "tool_use": + if tool_calls is None: + tool_calls = [] + tool_calls.append( + ToolContentBlock( + id=content_block.id, + type=content_block.type, + function=ToolCall( + name=content_block.name, + arguments=json.dumps(content_block.input), + ), + ) + ) + + if not tool_calls and resp.stop_reason == "tool_use": + logger.warning( + f"Tool use stop reason but no tool calls found in content. {resp}" + ) + + reasoning = None + for content_block in resp.content: + if hasattr(content_block, "type") and content_block.type == "thinking": + reasoning = content_block.thinking + break + + return LLMResponse( + raw_response=resp, + prompt=prompt, + response=( + resp.content[0].name + if isinstance(resp.content[0], anthropic.types.ToolUseBlock) + else getattr(resp.content[0], "text", "") + ), + tool_calls=tool_calls, + prompt_tokens=resp.usage.input_tokens, + completion_tokens=resp.usage.output_tokens, + reasoning=reasoning, + ) + except anthropic.APIError as e: + error_message = f"Anthropic API error: {str(e)}" + logger.error(error_message) + raise ValueError(error_message) + elif provider == "groq": + if tools: + raise ValueError("Groq does not support tools.") + + client = AsyncGroq(api_key=credentials.api_key.get_secret_value()) + response_format = {"type": "json_object"} if json_format else None + response = await client.chat.completions.create( + model=llm_model.value, + messages=prompt, # type: ignore + response_format=response_format, # type: ignore + max_tokens=max_tokens, + ) + return LLMResponse( + raw_response=response.choices[0].message, + prompt=prompt, + response=response.choices[0].message.content or "", + tool_calls=None, + prompt_tokens=response.usage.prompt_tokens if response.usage else 0, + completion_tokens=response.usage.completion_tokens if response.usage else 0, + reasoning=None, + ) + elif provider == "ollama": + if tools: + raise ValueError("Ollama does not support tools.") + + client = ollama.AsyncClient(host=ollama_host) + sys_messages = [p["content"] for p in prompt if p["role"] == "system"] + usr_messages = [p["content"] for p in prompt if p["role"] != "system"] + response = await client.generate( + model=llm_model.value, + prompt=f"{sys_messages}\n\n{usr_messages}", + stream=False, + options={"num_ctx": max_tokens}, + ) + return LLMResponse( + raw_response=response.get("response") or "", + prompt=prompt, + response=response.get("response") or "", + tool_calls=None, + prompt_tokens=response.get("prompt_eval_count") or 0, + completion_tokens=response.get("eval_count") or 0, + reasoning=None, + ) + elif provider == "open_router": + tools_param = tools if tools else openai.NOT_GIVEN + client = openai.AsyncOpenAI( + base_url="https://openrouter.ai/api/v1", + api_key=credentials.api_key.get_secret_value(), + ) + + parallel_tool_calls_param = get_parallel_tool_calls_param( + llm_model, parallel_tool_calls + ) + + response = await client.chat.completions.create( + extra_headers={ + "HTTP-Referer": "https://agpt.co", + "X-Title": "AutoGPT", + }, + model=llm_model.value, + messages=prompt, # type: ignore + max_tokens=max_tokens, + tools=tools_param, # type: ignore + parallel_tool_calls=parallel_tool_calls_param, + ) + + # If there's no response, raise an error + if not response.choices: + if response: + raise ValueError(f"OpenRouter error: {response}") + else: + raise ValueError("No response from OpenRouter.") + + tool_calls = extract_openai_tool_calls(response) + reasoning = extract_openai_reasoning(response) + + return LLMResponse( + raw_response=response.choices[0].message, + prompt=prompt, + response=response.choices[0].message.content or "", + tool_calls=tool_calls, + prompt_tokens=response.usage.prompt_tokens if response.usage else 0, + completion_tokens=response.usage.completion_tokens if response.usage else 0, + reasoning=reasoning, + ) + elif provider == "llama_api": + tools_param = tools if tools else openai.NOT_GIVEN + client = openai.AsyncOpenAI( + base_url="https://api.llama.com/compat/v1/", + api_key=credentials.api_key.get_secret_value(), + ) + + parallel_tool_calls_param = get_parallel_tool_calls_param( + llm_model, parallel_tool_calls + ) + + response = await client.chat.completions.create( + extra_headers={ + "HTTP-Referer": "https://agpt.co", + "X-Title": "AutoGPT", + }, + model=llm_model.value, + messages=prompt, # type: ignore + max_tokens=max_tokens, + tools=tools_param, # type: ignore + parallel_tool_calls=parallel_tool_calls_param, + ) + + # If there's no response, raise an error + if not response.choices: + if response: + raise ValueError(f"Llama API error: {response}") + else: + raise ValueError("No response from Llama API.") + + tool_calls = extract_openai_tool_calls(response) + reasoning = extract_openai_reasoning(response) + + return LLMResponse( + raw_response=response.choices[0].message, + prompt=prompt, + response=response.choices[0].message.content or "", + tool_calls=tool_calls, + prompt_tokens=response.usage.prompt_tokens if response.usage else 0, + completion_tokens=response.usage.completion_tokens if response.usage else 0, + reasoning=reasoning, + ) + elif provider == "aiml_api": + client = openai.OpenAI( + base_url="https://api.aimlapi.com/v2", + api_key=credentials.api_key.get_secret_value(), + default_headers={ + "X-Project": "AutoGPT", + "X-Title": "AutoGPT", + "HTTP-Referer": "https://github.com/Significant-Gravitas/AutoGPT", + }, + ) + + completion = client.chat.completions.create( + model=llm_model.value, + messages=prompt, # type: ignore + max_tokens=max_tokens, + ) + + return LLMResponse( + raw_response=completion.choices[0].message, + prompt=prompt, + response=completion.choices[0].message.content or "", + tool_calls=None, + prompt_tokens=completion.usage.prompt_tokens if completion.usage else 0, + completion_tokens=( + completion.usage.completion_tokens if completion.usage else 0 + ), + reasoning=None, + ) + elif provider == "v0": + tools_param = tools if tools else openai.NOT_GIVEN + client = openai.AsyncOpenAI( + base_url="https://api.v0.dev/v1", + api_key=credentials.api_key.get_secret_value(), + ) + + response_format = None + if json_format: + response_format = {"type": "json_object"} + + parallel_tool_calls_param = get_parallel_tool_calls_param( + llm_model, parallel_tool_calls + ) + + response = await client.chat.completions.create( + model=llm_model.value, + messages=prompt, # type: ignore + response_format=response_format, # type: ignore + max_tokens=max_tokens, + tools=tools_param, # type: ignore + parallel_tool_calls=parallel_tool_calls_param, + ) + + tool_calls = extract_openai_tool_calls(response) + reasoning = extract_openai_reasoning(response) + + return LLMResponse( + raw_response=response.choices[0].message, + prompt=prompt, + response=response.choices[0].message.content or "", + tool_calls=tool_calls, + prompt_tokens=response.usage.prompt_tokens if response.usage else 0, + completion_tokens=response.usage.completion_tokens if response.usage else 0, + reasoning=reasoning, + ) + else: + raise ValueError(f"Unsupported LLM provider: {provider}") + +class AIBlockBase(Block, ABC): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.prompt = [] -class Message(BlockSchema): - role: MessageRole - content: str + def merge_llm_stats(self, block: "AIBlockBase"): + self.merge_stats(block.execution_stats) + self.prompt = block.prompt -class AIStructuredResponseGeneratorBlock(Block): +class AIStructuredResponseGeneratorBlock(AIBlockBase): class Input(BlockSchema): prompt: str = SchemaField( description="The prompt to send to the language model.", @@ -212,9 +769,14 @@ class Input(BlockSchema): description="Expected format of the response. If provided, the response will be validated against this format. " "The keys should be the expected fields in the response, and the values should be the description of the field.", ) + list_result: bool = SchemaField( + title="List Result", + default=False, + description="Whether the response should be a list of objects in the expected format.", + ) model: LlmModel = SchemaField( title="LLM Model", - default=LlmModel.GPT4_TURBO, + default=LlmModel.GPT4O, description="The language model to use for answering the prompt.", advanced=False, ) @@ -224,8 +786,8 @@ class Input(BlockSchema): default="", description="The system prompt to provide additional context to the model.", ) - conversation_history: list[Message] = SchemaField( - default=[], + conversation_history: list[dict] = SchemaField( + default_factory=list, description="The conversation history to provide context for the prompt.", ) retry: int = SchemaField( @@ -234,14 +796,20 @@ class Input(BlockSchema): description="Number of times to retry the LLM call if the response does not match the expected format.", ) prompt_values: dict[str, str] = SchemaField( - advanced=False, default={}, description="Values used to fill in the prompt." + advanced=False, + default_factory=dict, + description="Values used to fill in the prompt. The values can be used in the prompt by putting them in a double curly braces, e.g. {{variable_name}}.", ) max_tokens: int | None = SchemaField( advanced=True, default=None, description="The maximum number of tokens to generate in the chat completion.", ) - + compress_prompt_to_fit: bool = SchemaField( + advanced=True, + default=True, + description="Whether to compress the prompt to fit within the model's context window.", + ) ollama_host: str = SchemaField( advanced=True, default="localhost:11434", @@ -249,9 +817,10 @@ class Input(BlockSchema): ) class Output(BlockSchema): - response: dict[str, Any] = SchemaField( + response: dict[str, Any] | list[dict[str, Any]] = SchemaField( description="The response object generated by the language model." ) + prompt: list = SchemaField(description="The prompt sent to the language model.") error: str = SchemaField(description="Error message if the API call failed.") def __init__(self): @@ -262,7 +831,7 @@ def __init__(self): input_schema=AIStructuredResponseGeneratorBlock.Input, output_schema=AIStructuredResponseGeneratorBlock.Output, test_input={ - "model": LlmModel.GPT4_TURBO, + "model": LlmModel.GPT4O, "credentials": TEST_CREDENTIALS_INPUT, "expected_format": { "key1": "value1", @@ -271,176 +840,60 @@ def __init__(self): "prompt": "User prompt", }, test_credentials=TEST_CREDENTIALS, - test_output=("response", {"key1": "key1Value", "key2": "key2Value"}), + test_output=[ + ("response", {"key1": "key1Value", "key2": "key2Value"}), + ("prompt", list), + ], test_mock={ - "llm_call": lambda *args, **kwargs: ( - json.dumps( + "llm_call": lambda *args, **kwargs: LLMResponse( + raw_response="", + prompt=[""], + response=json.dumps( { "key1": "key1Value", "key2": "key2Value", } ), - 0, - 0, + tool_calls=None, + prompt_tokens=0, + completion_tokens=0, + reasoning=None, ) }, ) - @staticmethod - def llm_call( + async def llm_call( + self, credentials: APIKeyCredentials, llm_model: LlmModel, prompt: list[dict], json_format: bool, - max_tokens: int | None = None, + compress_prompt_to_fit: bool, + max_tokens: int | None, + tools: list[dict] | None = None, ollama_host: str = "localhost:11434", - ) -> tuple[str, int, int]: + ) -> LLMResponse: """ - Args: - api_key: API key for the LLM provider. - llm_model: The LLM model to use. - prompt: The prompt to send to the LLM. - json_format: Whether the response should be in JSON format. - max_tokens: The maximum number of tokens to generate in the chat completion. - ollama_host: The host for ollama to use - - Returns: - The response from the LLM. - The number of tokens used in the prompt. - The number of tokens used in the completion. + Test mocks work only on class functions, this wraps the llm_call function + so that it can be mocked withing the block testing framework. """ - provider = llm_model.metadata.provider - - if provider == "openai": - oai_client = openai.OpenAI(api_key=credentials.api_key.get_secret_value()) - response_format = None - - if llm_model in [LlmModel.O1_MINI, LlmModel.O1_PREVIEW]: - sys_messages = [p["content"] for p in prompt if p["role"] == "system"] - usr_messages = [p["content"] for p in prompt if p["role"] != "system"] - prompt = [ - {"role": "user", "content": "\n".join(sys_messages)}, - {"role": "user", "content": "\n".join(usr_messages)}, - ] - elif json_format: - response_format = {"type": "json_object"} - - response = oai_client.chat.completions.create( - model=llm_model.value, - messages=prompt, # type: ignore - response_format=response_format, # type: ignore - max_completion_tokens=max_tokens, - ) - - return ( - response.choices[0].message.content or "", - response.usage.prompt_tokens if response.usage else 0, - response.usage.completion_tokens if response.usage else 0, - ) - elif provider == "anthropic": - system_messages = [p["content"] for p in prompt if p["role"] == "system"] - sysprompt = " ".join(system_messages) - - messages = [] - last_role = None - for p in prompt: - if p["role"] in ["user", "assistant"]: - if p["role"] != last_role: - messages.append({"role": p["role"], "content": p["content"]}) - last_role = p["role"] - else: - # If the role is the same as the last one, combine the content - messages[-1]["content"] += "\n" + p["content"] - - client = anthropic.Anthropic(api_key=credentials.api_key.get_secret_value()) - try: - resp = client.messages.create( - model=llm_model.value, - system=sysprompt, - messages=messages, - max_tokens=max_tokens or 8192, - ) - - if not resp.content: - raise ValueError("No content returned from Anthropic.") - - return ( - ( - resp.content[0].name - if isinstance(resp.content[0], anthropic.types.ToolUseBlock) - else resp.content[0].text - ), - resp.usage.input_tokens, - resp.usage.output_tokens, - ) - except anthropic.APIError as e: - error_message = f"Anthropic API error: {str(e)}" - logger.error(error_message) - raise ValueError(error_message) - elif provider == "groq": - client = Groq(api_key=credentials.api_key.get_secret_value()) - response_format = {"type": "json_object"} if json_format else None - response = client.chat.completions.create( - model=llm_model.value, - messages=prompt, # type: ignore - response_format=response_format, # type: ignore - max_tokens=max_tokens, - ) - return ( - response.choices[0].message.content or "", - response.usage.prompt_tokens if response.usage else 0, - response.usage.completion_tokens if response.usage else 0, - ) - elif provider == "ollama": - client = ollama.Client(host=ollama_host) - sys_messages = [p["content"] for p in prompt if p["role"] == "system"] - usr_messages = [p["content"] for p in prompt if p["role"] != "system"] - response = client.generate( - model=llm_model.value, - prompt=f"{sys_messages}\n\n{usr_messages}", - stream=False, - ) - return ( - response.get("response") or "", - response.get("prompt_eval_count") or 0, - response.get("eval_count") or 0, - ) - elif provider == "open_router": - client = openai.OpenAI( - base_url="https://openrouter.ai/api/v1", - api_key=credentials.api_key.get_secret_value(), - ) - - response = client.chat.completions.create( - extra_headers={ - "HTTP-Referer": "https://agpt.co", - "X-Title": "AutoGPT", - }, - model=llm_model.value, - messages=prompt, # type: ignore - max_tokens=max_tokens, - ) - - # If there's no response, raise an error - if not response.choices: - if response: - raise ValueError(f"OpenRouter error: {response}") - else: - raise ValueError("No response from OpenRouter.") - - return ( - response.choices[0].message.content or "", - response.usage.prompt_tokens if response.usage else 0, - response.usage.completion_tokens if response.usage else 0, - ) - else: - raise ValueError(f"Unsupported LLM provider: {provider}") + self.prompt = prompt + return await llm_call( + credentials=credentials, + llm_model=llm_model, + prompt=prompt, + json_format=json_format, + max_tokens=max_tokens, + tools=tools, + ollama_host=ollama_host, + compress_prompt_to_fit=compress_prompt_to_fit, + ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: logger.debug(f"Calling LLM with input data: {input_data}") - prompt = [p.model_dump() for p in input_data.conversation_history] + prompt = [json.to_dict(p) for p in input_data.conversation_history] def trim_prompt(s: str) -> str: lines = s.strip().split("\n") @@ -448,8 +901,8 @@ def trim_prompt(s: str) -> str: values = input_data.prompt_values if values: - input_data.prompt = input_data.prompt.format(**values) - input_data.sys_prompt = input_data.sys_prompt.format(**values) + input_data.prompt = fmt.format_string(input_data.prompt, values) + input_data.sys_prompt = fmt.format_string(input_data.sys_prompt, values) if input_data.sys_prompt: prompt.append({"role": "system", "content": input_data.sys_prompt}) @@ -458,13 +911,22 @@ def trim_prompt(s: str) -> str: expected_format = [ f'"{k}": "{v}"' for k, v in input_data.expected_format.items() ] - format_prompt = ",\n ".join(expected_format) + if input_data.list_result: + format_prompt = ( + f'"results": [\n {{\n {", ".join(expected_format)}\n }}\n]' + ) + else: + format_prompt = "\n ".join(expected_format) + sys_prompt = trim_prompt( f""" |Reply strictly only in the following JSON format: |{{ | {format_prompt} |}} + | + |Ensure the response is valid JSON. Do not include any additional text outside of the JSON. + |If you cannot provide all the keys, provide an empty string for the values you cannot answer. """ ) prompt.append({"role": "system", "content": sys_prompt}) @@ -472,56 +934,82 @@ def trim_prompt(s: str) -> str: if input_data.prompt: prompt.append({"role": "user", "content": input_data.prompt}) - def parse_response(resp: str) -> tuple[dict[str, Any], str | None]: + def validate_response(parsed: object) -> str | None: try: - parsed = json.loads(resp) if not isinstance(parsed, dict): - return {}, f"Expected a dictionary, but got {type(parsed)}" + return f"Expected a dictionary, but got {type(parsed)}" miss_keys = set(input_data.expected_format.keys()) - set(parsed.keys()) if miss_keys: - return parsed, f"Missing keys: {miss_keys}" - return parsed, None + return f"Missing keys: {miss_keys}" + return None except JSONDecodeError as e: - return {}, f"JSON decode error: {e}" + return f"JSON decode error: {e}" - logger.info(f"LLM request: {prompt}") + logger.debug(f"LLM request: {prompt}") retry_prompt = "" llm_model = input_data.model for retry_count in range(input_data.retry): try: - response_text, input_token, output_token = self.llm_call( + llm_response = await self.llm_call( credentials=credentials, llm_model=llm_model, prompt=prompt, + compress_prompt_to_fit=input_data.compress_prompt_to_fit, json_format=bool(input_data.expected_format), ollama_host=input_data.ollama_host, max_tokens=input_data.max_tokens, ) + response_text = llm_response.response self.merge_stats( - { - "input_token_count": input_token, - "output_token_count": output_token, - } + NodeExecutionStats( + input_token_count=llm_response.prompt_tokens, + output_token_count=llm_response.completion_tokens, + ) ) - logger.info(f"LLM attempt-{retry_count} response: {response_text}") + logger.debug(f"LLM attempt-{retry_count} response: {response_text}") if input_data.expected_format: - parsed_dict, parsed_error = parse_response(response_text) - if not parsed_error: - yield "response", { - k: ( - json.loads(v) - if isinstance(v, str) - and v.startswith("[") - and v.endswith("]") - else (", ".join(v) if isinstance(v, list) else v) + + response_obj = json.loads(response_text) + + if input_data.list_result and isinstance(response_obj, dict): + if "results" in response_obj: + response_obj = response_obj.get("results", []) + elif len(response_obj) == 1: + response_obj = list(response_obj.values()) + + response_error = "\n".join( + [ + validation_error + for response_item in ( + response_obj + if isinstance(response_obj, list) + else [response_obj] ) - for k, v in parsed_dict.items() - } + if (validation_error := validate_response(response_item)) + ] + ) + + if not response_error: + self.merge_stats( + NodeExecutionStats( + llm_call_count=retry_count + 1, + llm_retry_count=retry_count, + ) + ) + yield "response", response_obj + yield "prompt", self.prompt return else: + self.merge_stats( + NodeExecutionStats( + llm_call_count=retry_count + 1, + llm_retry_count=retry_count, + ) + ) yield "response", {"response": response_text} + yield "prompt", self.prompt return retry_prompt = trim_prompt( @@ -533,26 +1021,29 @@ def parse_response(resp: str) -> tuple[dict[str, Any], str | None]: | |And this is the error: |-- - |{parsed_error} + |{response_error} |-- """ ) prompt.append({"role": "user", "content": retry_prompt}) except Exception as e: logger.exception(f"Error calling LLM: {e}") + if ( + "maximum context length" in str(e).lower() + or "token limit" in str(e).lower() + ): + if input_data.max_tokens is None: + input_data.max_tokens = llm_model.max_output_tokens or 4096 + input_data.max_tokens = int(input_data.max_tokens * 0.85) + logger.debug( + f"Reducing max_tokens to {input_data.max_tokens} for next attempt" + ) retry_prompt = f"Error calling LLM: {e}" - finally: - self.merge_stats( - { - "llm_call_count": retry_count + 1, - "llm_retry_count": retry_count, - } - ) raise RuntimeError(retry_prompt) -class AITextGeneratorBlock(Block): +class AITextGeneratorBlock(AIBlockBase): class Input(BlockSchema): prompt: str = SchemaField( description="The prompt to send to the language model. You can use any of the {keys} from Prompt Values to fill in the prompt with values from the prompt values dictionary by putting them in curly braces.", @@ -560,7 +1051,7 @@ class Input(BlockSchema): ) model: LlmModel = SchemaField( title="LLM Model", - default=LlmModel.GPT4_TURBO, + default=LlmModel.GPT4O, description="The language model to use for answering the prompt.", advanced=False, ) @@ -576,7 +1067,9 @@ class Input(BlockSchema): description="Number of times to retry the LLM call if the response does not match the expected format.", ) prompt_values: dict[str, str] = SchemaField( - advanced=False, default={}, description="Values used to fill in the prompt." + advanced=False, + default_factory=dict, + description="Values used to fill in the prompt. The values can be used in the prompt by putting them in a double curly braces, e.g. {{variable_name}}.", ) ollama_host: str = SchemaField( advanced=True, @@ -593,6 +1086,7 @@ class Output(BlockSchema): response: str = SchemaField( description="The response generated by the language model." ) + prompt: list = SchemaField(description="The prompt sent to the language model.") error: str = SchemaField(description="Error message if the API call failed.") def __init__(self): @@ -607,28 +1101,36 @@ def __init__(self): "credentials": TEST_CREDENTIALS_INPUT, }, test_credentials=TEST_CREDENTIALS, - test_output=("response", "Response text"), + test_output=[ + ("response", "Response text"), + ("prompt", list), + ], test_mock={"llm_call": lambda *args, **kwargs: "Response text"}, ) - def llm_call( + async def llm_call( self, input_data: AIStructuredResponseGeneratorBlock.Input, credentials: APIKeyCredentials, - ) -> str: + ) -> dict: block = AIStructuredResponseGeneratorBlock() - response = block.run_once(input_data, "response", credentials=credentials) - self.merge_stats(block.execution_stats) + response = await block.run_once(input_data, "response", credentials=credentials) + self.merge_llm_stats(block) return response["response"] - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: object_input_data = AIStructuredResponseGeneratorBlock.Input( - **{attr: getattr(input_data, attr) for attr in input_data.model_fields}, + **{ + attr: getattr(input_data, attr) + for attr in AITextGeneratorBlock.Input.model_fields + }, expected_format={}, ) - yield "response", self.llm_call(object_input_data, credentials) + response = await self.llm_call(object_input_data, credentials) + yield "response", response + yield "prompt", self.prompt class SummaryStyle(Enum): @@ -638,7 +1140,7 @@ class SummaryStyle(Enum): NUMBERED_LIST = "numbered list" -class AITextSummarizerBlock(Block): +class AITextSummarizerBlock(AIBlockBase): class Input(BlockSchema): text: str = SchemaField( description="The text to summarize.", @@ -646,7 +1148,7 @@ class Input(BlockSchema): ) model: LlmModel = SchemaField( title="LLM Model", - default=LlmModel.GPT4_TURBO, + default=LlmModel.GPT4O, description="The language model to use for summarizing the text.", ) focus: str = SchemaField( @@ -681,6 +1183,7 @@ class Input(BlockSchema): class Output(BlockSchema): summary: str = SchemaField(description="The final summary of the text.") + prompt: list = SchemaField(description="The prompt sent to the language model.") error: str = SchemaField(description="Error message if the API call failed.") def __init__(self): @@ -695,7 +1198,10 @@ def __init__(self): "credentials": TEST_CREDENTIALS_INPUT, }, test_credentials=TEST_CREDENTIALS, - test_output=("summary", "Final summary of a long text"), + test_output=[ + ("summary", "Final summary of a long text"), + ("prompt", list), + ], test_mock={ "llm_call": lambda input_data, credentials: ( {"final_summary": "Final summary of a long text"} @@ -705,24 +1211,29 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: - for output in self._run(input_data, credentials): - yield output + async for output_name, output_data in self._run(input_data, credentials): + yield output_name, output_data - def _run(self, input_data: Input, credentials: APIKeyCredentials) -> BlockOutput: + async def _run( + self, input_data: Input, credentials: APIKeyCredentials + ) -> BlockOutput: chunks = self._split_text( input_data.text, input_data.max_tokens, input_data.chunk_overlap ) summaries = [] for chunk in chunks: - chunk_summary = self._summarize_chunk(chunk, input_data, credentials) + chunk_summary = await self._summarize_chunk(chunk, input_data, credentials) summaries.append(chunk_summary) - final_summary = self._combine_summaries(summaries, input_data, credentials) + final_summary = await self._combine_summaries( + summaries, input_data, credentials + ) yield "summary", final_summary + yield "prompt", self.prompt @staticmethod def _split_text(text: str, max_tokens: int, overlap: int) -> list[str]: @@ -736,22 +1247,22 @@ def _split_text(text: str, max_tokens: int, overlap: int) -> list[str]: return chunks - def llm_call( + async def llm_call( self, input_data: AIStructuredResponseGeneratorBlock.Input, credentials: APIKeyCredentials, ) -> dict: block = AIStructuredResponseGeneratorBlock() - response = block.run_once(input_data, "response", credentials=credentials) - self.merge_stats(block.execution_stats) + response = await block.run_once(input_data, "response", credentials=credentials) + self.merge_llm_stats(block) return response - def _summarize_chunk( + async def _summarize_chunk( self, chunk: str, input_data: Input, credentials: APIKeyCredentials ) -> str: prompt = f"Summarize the following text in a {input_data.style} form. Focus your summary on the topic of `{input_data.focus}` if present, otherwise just provide a general summary:\n\n```{chunk}```" - llm_response = self.llm_call( + llm_response = await self.llm_call( AIStructuredResponseGeneratorBlock.Input( prompt=prompt, credentials=input_data.credentials, @@ -763,7 +1274,7 @@ def _summarize_chunk( return llm_response["summary"] - def _combine_summaries( + async def _combine_summaries( self, summaries: list[str], input_data: Input, credentials: APIKeyCredentials ) -> str: combined_text = "\n\n".join(summaries) @@ -771,7 +1282,7 @@ def _combine_summaries( if len(combined_text.split()) <= input_data.max_tokens: prompt = f"Provide a final summary of the following section summaries in a {input_data.style} form, focus your summary on the topic of `{input_data.focus}` if present:\n\n ```{combined_text}```\n\n Just respond with the final_summary in the format specified." - llm_response = self.llm_call( + llm_response = await self.llm_call( AIStructuredResponseGeneratorBlock.Input( prompt=prompt, credentials=input_data.credentials, @@ -786,7 +1297,8 @@ def _combine_summaries( return llm_response["final_summary"] else: # If combined summaries are still too long, recursively summarize - return self._run( + block = AITextSummarizerBlock() + return await block.run_once( AITextSummarizerBlock.Input( text=combined_text, credentials=input_data.credentials, @@ -794,20 +1306,25 @@ def _combine_summaries( max_tokens=input_data.max_tokens, chunk_overlap=input_data.chunk_overlap, ), + "summary", credentials=credentials, - ).send(None)[ - 1 - ] # Get the first yielded value + ) -class AIConversationBlock(Block): +class AIConversationBlock(AIBlockBase): class Input(BlockSchema): - messages: List[Message] = SchemaField( - description="List of messages in the conversation.", min_length=1 + prompt: str = SchemaField( + description="The prompt to send to the language model.", + placeholder="Enter your prompt here...", + default="", + advanced=False, + ) + messages: List[Any] = SchemaField( + description="List of messages in the conversation.", ) model: LlmModel = SchemaField( title="LLM Model", - default=LlmModel.GPT4_TURBO, + default=LlmModel.GPT4O, description="The language model to use for the conversation.", ) credentials: AICredentials = AICredentialsField() @@ -826,6 +1343,7 @@ class Output(BlockSchema): response: str = SchemaField( description="The model's response to the conversation." ) + prompt: list = SchemaField(description="The prompt sent to the language model.") error: str = SchemaField(description="Error message if the API call failed.") def __init__(self): @@ -845,48 +1363,52 @@ def __init__(self): }, {"role": "user", "content": "Where was it played?"}, ], - "model": LlmModel.GPT4_TURBO, + "model": LlmModel.GPT4O, "credentials": TEST_CREDENTIALS_INPUT, }, test_credentials=TEST_CREDENTIALS, - test_output=( - "response", - "The 2020 World Series was played at Globe Life Field in Arlington, Texas.", - ), + test_output=[ + ( + "response", + "The 2020 World Series was played at Globe Life Field in Arlington, Texas.", + ), + ("prompt", list), + ], test_mock={ "llm_call": lambda *args, **kwargs: "The 2020 World Series was played at Globe Life Field in Arlington, Texas." }, ) - def llm_call( + async def llm_call( self, input_data: AIStructuredResponseGeneratorBlock.Input, credentials: APIKeyCredentials, - ) -> str: + ) -> dict: block = AIStructuredResponseGeneratorBlock() - response = block.run_once(input_data, "response", credentials=credentials) - self.merge_stats(block.execution_stats) - return response["response"] + response = await block.run_once(input_data, "response", credentials=credentials) + self.merge_llm_stats(block) + return response - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: - response = self.llm_call( + response = await self.llm_call( AIStructuredResponseGeneratorBlock.Input( - prompt="", + prompt=input_data.prompt, credentials=input_data.credentials, model=input_data.model, conversation_history=input_data.messages, max_tokens=input_data.max_tokens, expected_format={}, + ollama_host=input_data.ollama_host, ), credentials=credentials, ) - yield "response", response + yield "prompt", self.prompt -class AIListGeneratorBlock(Block): +class AIListGeneratorBlock(AIBlockBase): class Input(BlockSchema): focus: str | None = SchemaField( description="The focus of the list to generate.", @@ -902,7 +1424,7 @@ class Input(BlockSchema): ) model: LlmModel = SchemaField( title="LLM Model", - default=LlmModel.GPT4_TURBO, + default=LlmModel.GPT4O, description="The language model to use for generating the list.", advanced=True, ) @@ -929,6 +1451,7 @@ class Output(BlockSchema): list_item: str = SchemaField( description="Each individual item in the list.", ) + prompt: list = SchemaField(description="The prompt sent to the language model.") error: str = SchemaField( description="Error message if the list generation failed." ) @@ -950,7 +1473,7 @@ def __init__(self): "drawing explorers to uncover its mysteries. Each planet showcases the limitless possibilities of " "fictional worlds." ), - "model": LlmModel.GPT4_TURBO, + "model": LlmModel.GPT4O, "credentials": TEST_CREDENTIALS_INPUT, "max_retries": 3, }, @@ -960,6 +1483,7 @@ def __init__(self): "generated_list", ["Zylora Prime", "Kharon-9", "Vortexia", "Oceara", "Draknos"], ), + ("prompt", list), ("list_item", "Zylora Prime"), ("list_item", "Kharon-9"), ("list_item", "Vortexia"), @@ -973,13 +1497,16 @@ def __init__(self): }, ) - @staticmethod - def llm_call( + async def llm_call( + self, input_data: AIStructuredResponseGeneratorBlock.Input, credentials: APIKeyCredentials, ) -> dict[str, str]: llm_block = AIStructuredResponseGeneratorBlock() - response = llm_block.run_once(input_data, "response", credentials=credentials) + response = await llm_block.run_once( + input_data, "response", credentials=credentials + ) + self.merge_llm_stats(llm_block) return response @staticmethod @@ -1001,7 +1528,7 @@ def string_to_list(string): logger.error(f"Failed to convert string to list: {e}") raise ValueError("Invalid list format. Could not convert to list.") - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: logger.debug(f"Starting AIListGeneratorBlock.run with input data: {input_data}") @@ -1067,7 +1594,7 @@ def run( for attempt in range(input_data.max_retries): try: logger.debug("Calling LLM") - llm_response = self.llm_call( + llm_response = await self.llm_call( AIStructuredResponseGeneratorBlock.Input( sys_prompt=sys_prompt, prompt=prompt, @@ -1093,6 +1620,7 @@ def run( # If we reach here, we have a valid Python list logger.debug("Successfully generated a valid Python list") yield "generated_list", parsed_list + yield "prompt", self.prompt # Yield each item in the list for item in parsed_list: diff --git a/autogpt_platform/backend/backend/blocks/maths.py b/autogpt_platform/backend/backend/blocks/maths.py index cb65de1c0965..0559d9673d02 100644 --- a/autogpt_platform/backend/backend/blocks/maths.py +++ b/autogpt_platform/backend/backend/blocks/maths.py @@ -52,7 +52,7 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: operation = input_data.operation a = input_data.a b = input_data.b @@ -107,7 +107,7 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: collection = input_data.collection try: diff --git a/autogpt_platform/backend/backend/blocks/media.py b/autogpt_platform/backend/backend/blocks/media.py new file mode 100644 index 000000000000..d642eac5e4a3 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/media.py @@ -0,0 +1,254 @@ +import os +import tempfile +from typing import Literal, Optional + +from moviepy.audio.io.AudioFileClip import AudioFileClip +from moviepy.video.fx.Loop import Loop +from moviepy.video.io.VideoFileClip import VideoFileClip + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField +from backend.util.file import MediaFileType, get_exec_file_path, store_media_file + + +class MediaDurationBlock(Block): + + class Input(BlockSchema): + media_in: MediaFileType = SchemaField( + description="Media input (URL, data URI, or local path)." + ) + is_video: bool = SchemaField( + description="Whether the media is a video (True) or audio (False).", + default=True, + ) + + class Output(BlockSchema): + duration: float = SchemaField( + description="Duration of the media file (in seconds)." + ) + error: str = SchemaField( + description="Error message if something fails.", default="" + ) + + def __init__(self): + super().__init__( + id="d8b91fd4-da26-42d4-8ecb-8b196c6d84b6", + description="Block to get the duration of a media file.", + categories={BlockCategory.MULTIMEDIA}, + input_schema=MediaDurationBlock.Input, + output_schema=MediaDurationBlock.Output, + ) + + async def run( + self, + input_data: Input, + *, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + # 1) Store the input media locally + local_media_path = await store_media_file( + graph_exec_id=graph_exec_id, + file=input_data.media_in, + user_id=user_id, + return_content=False, + ) + media_abspath = get_exec_file_path(graph_exec_id, local_media_path) + + # 2) Load the clip + if input_data.is_video: + clip = VideoFileClip(media_abspath) + else: + clip = AudioFileClip(media_abspath) + + yield "duration", clip.duration + + +class LoopVideoBlock(Block): + """ + Block for looping (repeating) a video clip until a given duration or number of loops. + """ + + class Input(BlockSchema): + video_in: MediaFileType = SchemaField( + description="The input video (can be a URL, data URI, or local path)." + ) + # Provide EITHER a `duration` or `n_loops` or both. We'll demonstrate `duration`. + duration: Optional[float] = SchemaField( + description="Target duration (in seconds) to loop the video to. If omitted, defaults to no looping.", + default=None, + ge=0.0, + ) + n_loops: Optional[int] = SchemaField( + description="Number of times to repeat the video. If omitted, defaults to 1 (no repeat).", + default=None, + ge=1, + ) + output_return_type: Literal["file_path", "data_uri"] = SchemaField( + description="How to return the output video. Either a relative path or base64 data URI.", + default="file_path", + ) + + class Output(BlockSchema): + video_out: str = SchemaField( + description="Looped video returned either as a relative path or a data URI." + ) + error: str = SchemaField( + description="Error message if something fails.", default="" + ) + + def __init__(self): + super().__init__( + id="8bf9eef6-5451-4213-b265-25306446e94b", + description="Block to loop a video to a given duration or number of repeats.", + categories={BlockCategory.MULTIMEDIA}, + input_schema=LoopVideoBlock.Input, + output_schema=LoopVideoBlock.Output, + ) + + async def run( + self, + input_data: Input, + *, + node_exec_id: str, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + # 1) Store the input video locally + local_video_path = await store_media_file( + graph_exec_id=graph_exec_id, + file=input_data.video_in, + user_id=user_id, + return_content=False, + ) + input_abspath = get_exec_file_path(graph_exec_id, local_video_path) + + # 2) Load the clip + clip = VideoFileClip(input_abspath) + + # 3) Apply the loop effect + looped_clip = clip + if input_data.duration: + # Loop until we reach the specified duration + looped_clip = looped_clip.with_effects([Loop(duration=input_data.duration)]) + elif input_data.n_loops: + looped_clip = looped_clip.with_effects([Loop(n=input_data.n_loops)]) + else: + raise ValueError("Either 'duration' or 'n_loops' must be provided.") + + assert isinstance(looped_clip, VideoFileClip) + + # 4) Save the looped output + output_filename = MediaFileType( + f"{node_exec_id}_looped_{os.path.basename(local_video_path)}" + ) + output_abspath = get_exec_file_path(graph_exec_id, output_filename) + + looped_clip = looped_clip.with_audio(clip.audio) + looped_clip.write_videofile(output_abspath, codec="libx264", audio_codec="aac") + + # Return as data URI + video_out = await store_media_file( + graph_exec_id=graph_exec_id, + file=output_filename, + user_id=user_id, + return_content=input_data.output_return_type == "data_uri", + ) + + yield "video_out", video_out + + +class AddAudioToVideoBlock(Block): + """ + Block that adds (attaches) an audio track to an existing video. + Optionally scale the volume of the new track. + """ + + class Input(BlockSchema): + video_in: MediaFileType = SchemaField( + description="Video input (URL, data URI, or local path)." + ) + audio_in: MediaFileType = SchemaField( + description="Audio input (URL, data URI, or local path)." + ) + volume: float = SchemaField( + description="Volume scale for the newly attached audio track (1.0 = original).", + default=1.0, + ) + output_return_type: Literal["file_path", "data_uri"] = SchemaField( + description="Return the final output as a relative path or base64 data URI.", + default="file_path", + ) + + class Output(BlockSchema): + video_out: MediaFileType = SchemaField( + description="Final video (with attached audio), as a path or data URI." + ) + error: str = SchemaField( + description="Error message if something fails.", default="" + ) + + def __init__(self): + super().__init__( + id="3503748d-62b6-4425-91d6-725b064af509", + description="Block to attach an audio file to a video file using moviepy.", + categories={BlockCategory.MULTIMEDIA}, + input_schema=AddAudioToVideoBlock.Input, + output_schema=AddAudioToVideoBlock.Output, + ) + + async def run( + self, + input_data: Input, + *, + node_exec_id: str, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + # 1) Store the inputs locally + local_video_path = await store_media_file( + graph_exec_id=graph_exec_id, + file=input_data.video_in, + user_id=user_id, + return_content=False, + ) + local_audio_path = await store_media_file( + graph_exec_id=graph_exec_id, + file=input_data.audio_in, + user_id=user_id, + return_content=False, + ) + + abs_temp_dir = os.path.join(tempfile.gettempdir(), "exec_file", graph_exec_id) + video_abspath = os.path.join(abs_temp_dir, local_video_path) + audio_abspath = os.path.join(abs_temp_dir, local_audio_path) + + # 2) Load video + audio with moviepy + video_clip = VideoFileClip(video_abspath) + audio_clip = AudioFileClip(audio_abspath) + # Optionally scale volume + if input_data.volume != 1.0: + audio_clip = audio_clip.with_volume_scaled(input_data.volume) + + # 3) Attach the new audio track + final_clip = video_clip.with_audio(audio_clip) + + # 4) Write to output file + output_filename = MediaFileType( + f"{node_exec_id}_audio_attached_{os.path.basename(local_video_path)}" + ) + output_abspath = os.path.join(abs_temp_dir, output_filename) + final_clip.write_videofile(output_abspath, codec="libx264", audio_codec="aac") + + # 5) Return either path or data URI + video_out = await store_media_file( + graph_exec_id=graph_exec_id, + file=output_filename, + user_id=user_id, + return_content=input_data.output_return_type == "data_uri", + ) + + yield "video_out", video_out diff --git a/autogpt_platform/backend/backend/blocks/medium.py b/autogpt_platform/backend/backend/blocks/medium.py index 6d871b4caac5..a8964ca940a1 100644 --- a/autogpt_platform/backend/backend/blocks/medium.py +++ b/autogpt_platform/backend/backend/blocks/medium.py @@ -13,7 +13,7 @@ SecretField, ) from backend.integrations.providers import ProviderName -from backend.util.request import requests +from backend.util.request import Requests TEST_CREDENTIALS = APIKeyCredentials( id="01234567-89ab-cdef-0123-456789abcdef", @@ -130,7 +130,7 @@ def __init__(self): test_credentials=TEST_CREDENTIALS, ) - def create_post( + async def create_post( self, api_key: SecretStr, author_id, @@ -160,18 +160,17 @@ def create_post( "notifyFollowers": notify_followers, } - response = requests.post( + response = await Requests().post( f"https://api.medium.com/v1/users/{author_id}/posts", headers=headers, json=data, ) - return response.json() - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: - response = self.create_post( + response = await self.create_post( credentials.api_key, input_data.author_id.get_secret_value(), input_data.title, diff --git a/autogpt_platform/backend/backend/blocks/mem0.py b/autogpt_platform/backend/backend/blocks/mem0.py new file mode 100644 index 000000000000..0aae1c316a11 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/mem0.py @@ -0,0 +1,466 @@ +from typing import Any, Literal, Optional, Union + +from mem0 import MemoryClient +from pydantic import BaseModel, SecretStr + +from backend.data.block import Block, BlockOutput, BlockSchema +from backend.data.model import ( + APIKeyCredentials, + CredentialsField, + CredentialsMetaInput, + SchemaField, +) +from backend.integrations.providers import ProviderName + +TEST_CREDENTIALS = APIKeyCredentials( + id="8cc8b2c5-d3e4-4b1c-84ad-e1e9fe2a0122", + provider="mem0", + api_key=SecretStr("mock-mem0-api-key"), + title="Mock Mem0 API key", + expires_at=None, +) + +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} + + +class Mem0Base: + """Base class with shared utilities for Mem0 blocks""" + + @staticmethod + def _get_client(credentials: APIKeyCredentials) -> MemoryClient: + """Get initialized Mem0 client""" + return MemoryClient(api_key=credentials.api_key.get_secret_value()) + + +Filter = dict[str, list[dict[str, str | dict[str, list[str]]]]] + + +class Conversation(BaseModel): + discriminator: Literal["conversation"] + messages: list[dict[str, str]] + + +class Content(BaseModel): + discriminator: Literal["content"] + content: str + + +class AddMemoryBlock(Block, Mem0Base): + """Block for adding memories to Mem0 + + Always limited by user_id and optional graph_id and graph_exec_id""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput[ + Literal[ProviderName.MEM0], Literal["api_key"] + ] = CredentialsField(description="Mem0 API key credentials") + content: Union[Content, Conversation] = SchemaField( + discriminator="discriminator", + description="Content to add - either a string or list of message objects as output from an AI block", + default=Content(discriminator="content", content="I'm a vegetarian"), + ) + metadata: dict[str, Any] = SchemaField( + description="Optional metadata for the memory", default_factory=dict + ) + limit_memory_to_run: bool = SchemaField( + description="Limit the memory to the run", default=False + ) + limit_memory_to_agent: bool = SchemaField( + description="Limit the memory to the agent", default=True + ) + + class Output(BlockSchema): + action: str = SchemaField(description="Action of the operation") + memory: str = SchemaField(description="Memory created") + results: list[dict[str, str]] = SchemaField( + description="List of all results from the operation" + ) + error: str = SchemaField(description="Error message if operation fails") + + def __init__(self): + super().__init__( + id="dce97578-86be-45a4-ae50-f6de33fc935a", + description="Add new memories to Mem0 with user segmentation", + input_schema=AddMemoryBlock.Input, + output_schema=AddMemoryBlock.Output, + test_input=[ + { + "content": { + "discriminator": "conversation", + "messages": [{"role": "user", "content": "I'm a vegetarian"}], + }, + "metadata": {"food": "vegetarian"}, + "credentials": TEST_CREDENTIALS_INPUT, + }, + { + "content": { + "discriminator": "content", + "content": "I am a vegetarian", + }, + "metadata": {"food": "vegetarian"}, + "credentials": TEST_CREDENTIALS_INPUT, + }, + ], + test_output=[ + ("results", [{"event": "CREATED", "memory": "test memory"}]), + ("action", "CREATED"), + ("memory", "test memory"), + ("results", [{"event": "CREATED", "memory": "test memory"}]), + ("action", "CREATED"), + ("memory", "test memory"), + ], + test_credentials=TEST_CREDENTIALS, + test_mock={"_get_client": lambda credentials: MockMemoryClient()}, + ) + + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + user_id: str, + graph_id: str, + graph_exec_id: str, + **kwargs, + ) -> BlockOutput: + try: + client = self._get_client(credentials) + + if isinstance(input_data.content, Conversation): + messages = input_data.content.messages + elif isinstance(input_data.content, Content): + messages = [{"role": "user", "content": input_data.content.content}] + else: + messages = [{"role": "user", "content": str(input_data.content)}] + + params = { + "user_id": user_id, + "output_format": "v1.1", + "metadata": input_data.metadata, + } + + if input_data.limit_memory_to_run: + params["run_id"] = graph_exec_id + if input_data.limit_memory_to_agent: + params["agent_id"] = graph_id + + # Use the client to add memory + result = client.add( + messages, + **params, + ) + + results = result.get("results", []) + yield "results", results + + if len(results) > 0: + for result in results: + yield "action", result["event"] + yield "memory", result["memory"] + else: + yield "action", "NO_CHANGE" + + except Exception as e: + yield "error", str(e) + + +class SearchMemoryBlock(Block, Mem0Base): + """Block for searching memories in Mem0""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput[ + Literal[ProviderName.MEM0], Literal["api_key"] + ] = CredentialsField(description="Mem0 API key credentials") + query: str = SchemaField( + description="Search query", + advanced=False, + ) + trigger: bool = SchemaField( + description="An unused field that is used to (re-)trigger the block when you have no other inputs", + default=False, + advanced=False, + ) + categories_filter: list[str] = SchemaField( + description="Categories to filter by", + default_factory=list, + advanced=True, + ) + metadata_filter: Optional[dict[str, Any]] = SchemaField( + description="Optional metadata filters to apply", + default=None, + ) + limit_memory_to_run: bool = SchemaField( + description="Limit the memory to the run", default=False + ) + limit_memory_to_agent: bool = SchemaField( + description="Limit the memory to the agent", default=True + ) + + class Output(BlockSchema): + memories: Any = SchemaField(description="List of matching memories") + error: str = SchemaField(description="Error message if operation fails") + + def __init__(self): + super().__init__( + id="bd7c84e3-e073-4b75-810c-600886ec8a5b", + description="Search memories in Mem0 by user", + input_schema=SearchMemoryBlock.Input, + output_schema=SearchMemoryBlock.Output, + test_input={ + "query": "vegetarian preferences", + "credentials": TEST_CREDENTIALS_INPUT, + "top_k": 10, + "rerank": True, + }, + test_output=[ + ("memories", [{"id": "test-memory", "content": "test content"}]) + ], + test_credentials=TEST_CREDENTIALS, + test_mock={"_get_client": lambda credentials: MockMemoryClient()}, + ) + + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + user_id: str, + graph_id: str, + graph_exec_id: str, + **kwargs, + ) -> BlockOutput: + try: + client = self._get_client(credentials) + + filters: Filter = { + # This works with only one filter, so we can allow others to add on later + "AND": [ + {"user_id": user_id}, + ] + } + if input_data.categories_filter: + filters["AND"].append( + {"categories": {"contains": input_data.categories_filter}} + ) + if input_data.limit_memory_to_run: + filters["AND"].append({"run_id": graph_exec_id}) + if input_data.limit_memory_to_agent: + filters["AND"].append({"agent_id": graph_id}) + if input_data.metadata_filter: + filters["AND"].append({"metadata": input_data.metadata_filter}) + + result: list[dict[str, Any]] = client.search( + input_data.query, version="v2", filters=filters + ) + yield "memories", result + + except Exception as e: + yield "error", str(e) + + +class GetAllMemoriesBlock(Block, Mem0Base): + """Block for retrieving all memories from Mem0""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput[ + Literal[ProviderName.MEM0], Literal["api_key"] + ] = CredentialsField(description="Mem0 API key credentials") + trigger: bool = SchemaField( + description="An unused field that is used to trigger the block when you have no other inputs", + default=False, + advanced=False, + ) + categories: Optional[list[str]] = SchemaField( + description="Filter by categories", default=None + ) + metadata_filter: Optional[dict[str, Any]] = SchemaField( + description="Optional metadata filters to apply", + default=None, + ) + limit_memory_to_run: bool = SchemaField( + description="Limit the memory to the run", default=False + ) + limit_memory_to_agent: bool = SchemaField( + description="Limit the memory to the agent", default=True + ) + + class Output(BlockSchema): + memories: Any = SchemaField(description="List of memories") + error: str = SchemaField(description="Error message if operation fails") + + def __init__(self): + super().__init__( + id="45aee5bf-4767-45d1-a28b-e01c5aae9fc1", + description="Retrieve all memories from Mem0 with optional conversation filtering", + input_schema=GetAllMemoriesBlock.Input, + output_schema=GetAllMemoriesBlock.Output, + test_input={ + "metadata_filter": {"type": "test"}, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("memories", [{"id": "test-memory", "content": "test content"}]), + ], + test_credentials=TEST_CREDENTIALS, + test_mock={"_get_client": lambda credentials: MockMemoryClient()}, + ) + + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + user_id: str, + graph_id: str, + graph_exec_id: str, + **kwargs, + ) -> BlockOutput: + try: + client = self._get_client(credentials) + + filters: Filter = { + "AND": [ + {"user_id": user_id}, + ] + } + if input_data.limit_memory_to_run: + filters["AND"].append({"run_id": graph_exec_id}) + if input_data.limit_memory_to_agent: + filters["AND"].append({"agent_id": graph_id}) + if input_data.categories: + filters["AND"].append( + {"categories": {"contains": input_data.categories}} + ) + if input_data.metadata_filter: + filters["AND"].append({"metadata": input_data.metadata_filter}) + + memories: list[dict[str, Any]] = client.get_all( + filters=filters, + version="v2", + ) + + yield "memories", memories + + except Exception as e: + yield "error", str(e) + + +class GetLatestMemoryBlock(Block, Mem0Base): + """Block for retrieving the latest memory from Mem0""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput[ + Literal[ProviderName.MEM0], Literal["api_key"] + ] = CredentialsField(description="Mem0 API key credentials") + trigger: bool = SchemaField( + description="An unused field that is used to trigger the block when you have no other inputs", + default=False, + advanced=False, + ) + categories: Optional[list[str]] = SchemaField( + description="Filter by categories", default=None + ) + conversation_id: Optional[str] = SchemaField( + description="Optional conversation ID to retrieve the latest memory from (uses run_id)", + default=None, + ) + metadata_filter: Optional[dict[str, Any]] = SchemaField( + description="Optional metadata filters to apply", + default=None, + ) + limit_memory_to_run: bool = SchemaField( + description="Limit the memory to the run", default=False + ) + limit_memory_to_agent: bool = SchemaField( + description="Limit the memory to the agent", default=True + ) + + class Output(BlockSchema): + memory: Optional[dict[str, Any]] = SchemaField( + description="Latest memory if found" + ) + found: bool = SchemaField(description="Whether a memory was found") + error: str = SchemaField(description="Error message if operation fails") + + def __init__(self): + super().__init__( + id="0f9d81b5-a145-4c23-b87f-01d6bf37b677", + description="Retrieve the latest memory from Mem0 with optional key filtering", + input_schema=GetLatestMemoryBlock.Input, + output_schema=GetLatestMemoryBlock.Output, + test_input={ + "metadata_filter": {"type": "test"}, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ("memory", {"id": "test-memory", "content": "test content"}), + ("found", True), + ], + test_credentials=TEST_CREDENTIALS, + test_mock={"_get_client": lambda credentials: MockMemoryClient()}, + ) + + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + user_id: str, + graph_id: str, + graph_exec_id: str, + **kwargs, + ) -> BlockOutput: + try: + client = self._get_client(credentials) + + filters: Filter = { + "AND": [ + {"user_id": user_id}, + ] + } + if input_data.limit_memory_to_run: + filters["AND"].append({"run_id": graph_exec_id}) + if input_data.limit_memory_to_agent: + filters["AND"].append({"agent_id": graph_id}) + if input_data.categories: + filters["AND"].append( + {"categories": {"contains": input_data.categories}} + ) + if input_data.metadata_filter: + filters["AND"].append({"metadata": input_data.metadata_filter}) + + memories: list[dict[str, Any]] = client.get_all( + filters=filters, + version="v2", + ) + + if memories: + # Return the latest memory (first in the list as they're sorted by recency) + latest_memory = memories[0] + yield "memory", latest_memory + yield "found", True + else: + yield "memory", None + yield "found", False + + except Exception as e: + yield "error", str(e) + + +# Mock client for testing +class MockMemoryClient: + """Mock Mem0 client for testing""" + + def add(self, *args, **kwargs): + return {"results": [{"event": "CREATED", "memory": "test memory"}]} + + def search(self, *args, **kwargs) -> list[dict[str, Any]]: + return [{"id": "test-memory", "content": "test content"}] + + def get_all(self, *args, **kwargs) -> list[dict[str, str]]: + return [{"id": "test-memory", "content": "test content"}] diff --git a/autogpt_platform/backend/backend/blocks/nvidia/_auth.py b/autogpt_platform/backend/backend/blocks/nvidia/_auth.py new file mode 100644 index 000000000000..46f28f009e0d --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/nvidia/_auth.py @@ -0,0 +1,32 @@ +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import APIKeyCredentials, CredentialsField, CredentialsMetaInput +from backend.integrations.providers import ProviderName + +NvidiaCredentials = APIKeyCredentials +NvidiaCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.NVIDIA], + Literal["api_key"], +] + +TEST_CREDENTIALS = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="nvidia", + api_key=SecretStr("mock-nvidia-api-key"), + title="Mock Nvidia API key", + expires_at=None, +) + +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} + + +def NvidiaCredentialsField() -> NvidiaCredentialsInput: + """Creates an Nvidia credentials input on a block.""" + return CredentialsField(description="The Nvidia integration requires an API Key.") diff --git a/autogpt_platform/backend/backend/blocks/nvidia/deepfake.py b/autogpt_platform/backend/backend/blocks/nvidia/deepfake.py new file mode 100644 index 000000000000..f5205f6e72f6 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/nvidia/deepfake.py @@ -0,0 +1,86 @@ +from backend.blocks.nvidia._auth import ( + NvidiaCredentials, + NvidiaCredentialsField, + NvidiaCredentialsInput, +) +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField +from backend.util.request import Requests +from backend.util.type import MediaFileType + + +class NvidiaDeepfakeDetectBlock(Block): + class Input(BlockSchema): + credentials: NvidiaCredentialsInput = NvidiaCredentialsField() + image_base64: MediaFileType = SchemaField( + description="Image to analyze for deepfakes", + ) + return_image: bool = SchemaField( + description="Whether to return the processed image with markings", + default=False, + ) + + class Output(BlockSchema): + status: str = SchemaField( + description="Detection status (SUCCESS, ERROR, CONTENT_FILTERED)", + ) + image: MediaFileType = SchemaField( + description="Processed image with detection markings (if return_image=True)", + ) + is_deepfake: float = SchemaField( + description="Probability that the image is a deepfake (0-1)", + ) + + def __init__(self): + super().__init__( + id="8c7d0d67-e79c-44f6-92a1-c2600c8aac7f", + description="Detects potential deepfakes in images using Nvidia's AI API", + categories={BlockCategory.SAFETY}, + input_schema=NvidiaDeepfakeDetectBlock.Input, + output_schema=NvidiaDeepfakeDetectBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: NvidiaCredentials, **kwargs + ) -> BlockOutput: + url = "https://ai.api.nvidia.com/v1/cv/hive/deepfake-image-detection" + + headers = { + "accept": "application/json", + "content-type": "application/json", + "Authorization": f"Bearer {credentials.api_key.get_secret_value()}", + } + + image_data = f"data:image/jpeg;base64,{input_data.image_base64}" + + payload = { + "input": [image_data], + "return_image": input_data.return_image, + } + + try: + response = await Requests().post(url, headers=headers, json=payload) + data = response.json() + + result = data.get("data", [{}])[0] + + # Get deepfake probability from first bounding box if any + deepfake_prob = 0.0 + if result.get("bounding_boxes"): + deepfake_prob = result["bounding_boxes"][0].get("is_deepfake", 0.0) + + yield "status", result.get("status", "ERROR") + yield "is_deepfake", deepfake_prob + + if input_data.return_image: + image_data = result.get("image", "") + output_data = f"data:image/jpeg;base64,{image_data}" + yield "image", output_data + else: + yield "image", "" + + except Exception as e: + yield "error", str(e) + yield "status", "ERROR" + yield "is_deepfake", 0.0 + yield "image", "" diff --git a/autogpt_platform/backend/backend/blocks/persistence.py b/autogpt_platform/backend/backend/blocks/persistence.py new file mode 100644 index 000000000000..8b165569b560 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/persistence.py @@ -0,0 +1,146 @@ +import logging +from typing import Any, Literal + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField +from backend.util.clients import get_database_manager_async_client + +logger = logging.getLogger(__name__) + + +StorageScope = Literal["within_agent", "across_agents"] + + +def get_storage_key(key: str, scope: StorageScope, graph_id: str) -> str: + """Generate the storage key based on scope""" + if scope == "across_agents": + return f"global#{key}" + else: + return f"agent#{graph_id}#{key}" + + +class PersistInformationBlock(Block): + """Block for persisting key-value data for the current user with configurable scope""" + + class Input(BlockSchema): + key: str = SchemaField(description="Key to store the information under") + value: Any = SchemaField(description="Value to store") + scope: StorageScope = SchemaField( + description="Scope of persistence: within_agent (shared across all runs of this agent) or across_agents (shared across all agents for this user)", + default="within_agent", + ) + + class Output(BlockSchema): + value: Any = SchemaField(description="Value that was stored") + + def __init__(self): + super().__init__( + id="1d055e55-a2b9-4547-8311-907d05b0304d", + description="Persist key-value information for the current user", + categories={BlockCategory.DATA}, + input_schema=PersistInformationBlock.Input, + output_schema=PersistInformationBlock.Output, + test_input={ + "key": "user_preference", + "value": {"theme": "dark", "language": "en"}, + "scope": "within_agent", + }, + test_output=[ + ("value", {"theme": "dark", "language": "en"}), + ], + test_mock={ + "_store_data": lambda *args, **kwargs: { + "theme": "dark", + "language": "en", + } + }, + ) + + async def run( + self, + input_data: Input, + *, + user_id: str, + graph_id: str, + node_exec_id: str, + **kwargs, + ) -> BlockOutput: + # Determine the storage key based on scope + storage_key = get_storage_key(input_data.key, input_data.scope, graph_id) + + # Store the data + yield "value", await self._store_data( + user_id=user_id, + node_exec_id=node_exec_id, + key=storage_key, + data=input_data.value, + ) + + async def _store_data( + self, user_id: str, node_exec_id: str, key: str, data: Any + ) -> Any | None: + return await get_database_manager_async_client().set_execution_kv_data( + user_id=user_id, + node_exec_id=node_exec_id, + key=key, + data=data, + ) + + +class RetrieveInformationBlock(Block): + """Block for retrieving key-value data for the current user with configurable scope""" + + class Input(BlockSchema): + key: str = SchemaField(description="Key to retrieve the information for") + scope: StorageScope = SchemaField( + description="Scope of persistence: within_agent (shared across all runs of this agent) or across_agents (shared across all agents for this user)", + default="within_agent", + ) + default_value: Any = SchemaField( + description="Default value to return if key is not found", default=None + ) + + class Output(BlockSchema): + value: Any = SchemaField(description="Retrieved value or default value") + + def __init__(self): + super().__init__( + id="d8710fc9-6e29-481e-a7d5-165eb16f8471", + description="Retrieve key-value information for the current user", + categories={BlockCategory.DATA}, + input_schema=RetrieveInformationBlock.Input, + output_schema=RetrieveInformationBlock.Output, + test_input={ + "key": "user_preference", + "scope": "within_agent", + "default_value": {"theme": "light", "language": "en"}, + }, + test_output=[ + ("value", {"theme": "light", "language": "en"}), + ], + test_mock={"_retrieve_data": lambda *args, **kwargs: None}, + static_output=True, + ) + + async def run( + self, input_data: Input, *, user_id: str, graph_id: str, **kwargs + ) -> BlockOutput: + # Determine the storage key based on scope + storage_key = get_storage_key(input_data.key, input_data.scope, graph_id) + + # Retrieve the data + stored_value = await self._retrieve_data( + user_id=user_id, + key=storage_key, + ) + + if stored_value is not None: + yield "value", stored_value + else: + yield "value", input_data.default_value + + async def _retrieve_data(self, user_id: str, key: str) -> Any | None: + return await get_database_manager_async_client().get_execution_kv_data( + user_id=user_id, + key=key, + ) diff --git a/autogpt_platform/backend/backend/blocks/pinecone.py b/autogpt_platform/backend/backend/blocks/pinecone.py index adfbc2e33fcd..529940b7cffc 100644 --- a/autogpt_platform/backend/backend/blocks/pinecone.py +++ b/autogpt_platform/backend/backend/blocks/pinecone.py @@ -56,7 +56,7 @@ def __init__(self): output_schema=PineconeInitBlock.Output, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: pc = Pinecone(api_key=credentials.api_key.get_secret_value()) @@ -117,7 +117,7 @@ def __init__(self): output_schema=PineconeQueryBlock.Output, ) - def run( + async def run( self, input_data: Input, *, @@ -177,7 +177,8 @@ class Input(BlockSchema): description="Namespace to use in Pinecone", default="" ) metadata: dict = SchemaField( - description="Additional metadata to store with each vector", default={} + description="Additional metadata to store with each vector", + default_factory=dict, ) class Output(BlockSchema): @@ -194,7 +195,7 @@ def __init__(self): output_schema=PineconeInsertBlock.Output, ) - def run( + async def run( self, input_data: Input, *, diff --git a/autogpt_platform/backend/backend/blocks/reddit.py b/autogpt_platform/backend/backend/blocks/reddit.py index 53c6e02a3212..b7fddf0d9a20 100644 --- a/autogpt_platform/backend/backend/blocks/reddit.py +++ b/autogpt_platform/backend/backend/blocks/reddit.py @@ -1,22 +1,48 @@ from datetime import datetime, timezone -from typing import Iterator +from typing import Iterator, Literal import praw -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, SecretStr from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema -from backend.data.model import BlockSecret, SchemaField, SecretField +from backend.data.model import ( + CredentialsField, + CredentialsMetaInput, + SchemaField, + UserPasswordCredentials, +) +from backend.integrations.providers import ProviderName from backend.util.mock import MockObject +from backend.util.settings import Settings +RedditCredentials = UserPasswordCredentials +RedditCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.REDDIT], + Literal["user_password"], +] -class RedditCredentials(BaseModel): - client_id: BlockSecret = SecretField(key="reddit_client_id") - client_secret: BlockSecret = SecretField(key="reddit_client_secret") - username: BlockSecret = SecretField(key="reddit_username") - password: BlockSecret = SecretField(key="reddit_password") - user_agent: str = "AutoGPT:1.0 (by /u/autogpt)" - model_config = ConfigDict(title="Reddit Credentials") +def RedditCredentialsField() -> RedditCredentialsInput: + """Creates a Reddit credentials input on a block.""" + return CredentialsField( + description="The Reddit integration requires a username and password.", + ) + + +TEST_CREDENTIALS = UserPasswordCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="reddit", + username=SecretStr("mock-reddit-username"), + password=SecretStr("mock-reddit-password"), + title="Mock Reddit credentials", +) + +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} class RedditPost(BaseModel): @@ -31,13 +57,16 @@ class RedditComment(BaseModel): comment: str +settings = Settings() + + def get_praw(creds: RedditCredentials) -> praw.Reddit: client = praw.Reddit( - client_id=creds.client_id.get_secret_value(), - client_secret=creds.client_secret.get_secret_value(), + client_id=settings.secrets.reddit_client_id, + client_secret=settings.secrets.reddit_client_secret, username=creds.username.get_secret_value(), password=creds.password.get_secret_value(), - user_agent=creds.user_agent, + user_agent=settings.config.reddit_user_agent, ) me = client.user.me() if not me: @@ -48,11 +77,11 @@ def get_praw(creds: RedditCredentials) -> praw.Reddit: class GetRedditPostsBlock(Block): class Input(BlockSchema): - subreddit: str = SchemaField(description="Subreddit name") - creds: RedditCredentials = SchemaField( - description="Reddit credentials", - default=RedditCredentials(), + subreddit: str = SchemaField( + description="Subreddit name, excluding the /r/ prefix", + default="writingprompts", ) + credentials: RedditCredentialsInput = RedditCredentialsField() last_minutes: int | None = SchemaField( description="Post time to stop minutes ago while fetching posts", default=None, @@ -67,23 +96,22 @@ class Input(BlockSchema): class Output(BlockSchema): post: RedditPost = SchemaField(description="Reddit post") + posts: list[RedditPost] = SchemaField(description="List of all Reddit posts") def __init__(self): super().__init__( - disabled=True, id="c6731acb-4285-4ee1-bc9b-03d0766c370f", description="This block fetches Reddit posts from a defined subreddit name.", categories={BlockCategory.SOCIAL}, + disabled=( + not settings.secrets.reddit_client_id + or not settings.secrets.reddit_client_secret + ), input_schema=GetRedditPostsBlock.Input, output_schema=GetRedditPostsBlock.Output, + test_credentials=TEST_CREDENTIALS, test_input={ - "creds": { - "client_id": "client_id", - "client_secret": "client_secret", - "username": "username", - "password": "password", - "user_agent": "user_agent", - }, + "credentials": TEST_CREDENTIALS_INPUT, "subreddit": "subreddit", "last_post": "id3", "post_limit": 2, @@ -101,9 +129,26 @@ def __init__(self): id="id2", subreddit="subreddit", title="title2", body="body2" ), ), + ( + "posts", + [ + RedditPost( + id="id1", + subreddit="subreddit", + title="title1", + body="body1", + ), + RedditPost( + id="id2", + subreddit="subreddit", + title="title2", + body="body2", + ), + ], + ), ], test_mock={ - "get_posts": lambda _: [ + "get_posts": lambda input_data, credentials: [ MockObject(id="id1", title="title1", selftext="body1"), MockObject(id="id2", title="title2", selftext="body2"), MockObject(id="id3", title="title2", selftext="body2"), @@ -112,14 +157,19 @@ def __init__(self): ) @staticmethod - def get_posts(input_data: Input) -> Iterator[praw.reddit.Submission]: - client = get_praw(input_data.creds) + def get_posts( + input_data: Input, *, credentials: RedditCredentials + ) -> Iterator[praw.reddit.Submission]: + client = get_praw(credentials) subreddit = client.subreddit(input_data.subreddit) return subreddit.new(limit=input_data.post_limit or 10) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run( + self, input_data: Input, *, credentials: RedditCredentials, **kwargs + ) -> BlockOutput: current_time = datetime.now(tz=timezone.utc) - for post in self.get_posts(input_data): + all_posts = [] + for post in self.get_posts(input_data=input_data, credentials=credentials): if input_data.last_minutes: post_datetime = datetime.fromtimestamp( post.created_utc, tz=timezone.utc @@ -131,19 +181,21 @@ def run(self, input_data: Input, **kwargs) -> BlockOutput: if input_data.last_post and post.id == input_data.last_post: break - yield "post", RedditPost( + reddit_post = RedditPost( id=post.id, subreddit=input_data.subreddit, title=post.title, body=post.selftext, ) + all_posts.append(reddit_post) + yield "post", reddit_post + + yield "posts", all_posts class PostRedditCommentBlock(Block): class Input(BlockSchema): - creds: RedditCredentials = SchemaField( - description="Reddit credentials", default=RedditCredentials() - ) + credentials: RedditCredentialsInput = RedditCredentialsField() data: RedditComment = SchemaField(description="Reddit comment") class Output(BlockSchema): @@ -156,7 +208,15 @@ def __init__(self): categories={BlockCategory.SOCIAL}, input_schema=PostRedditCommentBlock.Input, output_schema=PostRedditCommentBlock.Output, - test_input={"data": {"post_id": "id", "comment": "comment"}}, + disabled=( + not settings.secrets.reddit_client_id + or not settings.secrets.reddit_client_secret + ), + test_credentials=TEST_CREDENTIALS, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "data": {"post_id": "id", "comment": "comment"}, + }, test_output=[("comment_id", "dummy_comment_id")], test_mock={"reply_post": lambda creds, comment: "dummy_comment_id"}, ) @@ -170,5 +230,7 @@ def reply_post(creds: RedditCredentials, comment: RedditComment) -> str: raise ValueError("Failed to post comment.") return new_comment.id - def run(self, input_data: Input, **kwargs) -> BlockOutput: - yield "comment_id", self.reply_post(input_data.creds, input_data.data) + async def run( + self, input_data: Input, *, credentials: RedditCredentials, **kwargs + ) -> BlockOutput: + yield "comment_id", self.reply_post(credentials, input_data.data) diff --git a/autogpt_platform/backend/backend/blocks/replicate/_auth.py b/autogpt_platform/backend/backend/blocks/replicate/_auth.py new file mode 100644 index 000000000000..681679333fd0 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/replicate/_auth.py @@ -0,0 +1,24 @@ +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import APIKeyCredentials, CredentialsMetaInput, ProviderName + +TEST_CREDENTIALS = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="replicate", + api_key=SecretStr("mock-replicate-api-key"), + title="Mock Replicate API key", + expires_at=None, +) +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.type, +} + +ReplicateCredentials = APIKeyCredentials +ReplicateCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.REPLICATE], Literal["api_key"] +] diff --git a/autogpt_platform/backend/backend/blocks/replicate/_helper.py b/autogpt_platform/backend/backend/blocks/replicate/_helper.py new file mode 100644 index 000000000000..25fcb4fdcfcd --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/replicate/_helper.py @@ -0,0 +1,39 @@ +import logging + +from replicate.helpers import FileOutput + +logger = logging.getLogger(__name__) + +ReplicateOutputs = FileOutput | list[FileOutput] | list[str] | str | list[dict] + + +def extract_result(output: ReplicateOutputs) -> str: + result = ( + "Unable to process result. Please contact us with the models and inputs used" + ) + # Check if output is a list or a string and extract accordingly; otherwise, assign a default message + if isinstance(output, list) and len(output) > 0: + # we could use something like all(output, FileOutput) but it will be slower so we just type ignore + if isinstance(output[0], FileOutput): + result = output[0].url # If output is a list, get the first element + elif isinstance(output[0], str): + result = "".join( + output # type: ignore we're already not a file output here + ) # type:ignore If output is a list and a str, join the elements the first element. Happens if its text + elif isinstance(output[0], dict): + result = str(output[0]) + else: + logger.error( + "Replicate generated a new output type that's not a file output or a str in a replicate block" + ) + elif isinstance(output, FileOutput): + result = output.url # If output is a FileOutput, use the url + elif isinstance(output, str): + result = output # If output is a string (for some reason due to their janky type hinting), use it directly + else: + result = "No output received" # Fallback message if output is not as expected + logger.error( + "We somehow didn't get an output from a replicate block. This is almost certainly an error" + ) + + return result diff --git a/autogpt_platform/backend/backend/blocks/replicate/flux_advanced.py b/autogpt_platform/backend/backend/blocks/replicate/flux_advanced.py new file mode 100644 index 000000000000..477b1fa3e2e8 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/replicate/flux_advanced.py @@ -0,0 +1,204 @@ +import os +from enum import Enum + +from pydantic import SecretStr +from replicate.client import Client as ReplicateClient + +from backend.blocks.replicate._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + ReplicateCredentialsInput, +) +from backend.blocks.replicate._helper import ReplicateOutputs, extract_result +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import APIKeyCredentials, CredentialsField, SchemaField + + +# Model name enum +class ReplicateFluxModelName(str, Enum): + FLUX_SCHNELL = ("Flux Schnell",) + FLUX_PRO = ("Flux Pro",) + FLUX_PRO1_1 = ("Flux Pro 1.1",) + + @property + def api_name(self): + api_names = { + ReplicateFluxModelName.FLUX_SCHNELL: "black-forest-labs/flux-schnell", + ReplicateFluxModelName.FLUX_PRO: "black-forest-labs/flux-pro", + ReplicateFluxModelName.FLUX_PRO1_1: "black-forest-labs/flux-1.1-pro", + } + return api_names[self] + + +# Image type Enum +class ImageType(str, Enum): + WEBP = "webp" + JPG = "jpg" + PNG = "png" + + +class ReplicateFluxAdvancedModelBlock(Block): + class Input(BlockSchema): + credentials: ReplicateCredentialsInput = CredentialsField( + description="The Replicate integration can be used with " + "any API key with sufficient permissions for the blocks it is used on.", + ) + prompt: str = SchemaField( + description="Text prompt for image generation", + placeholder="e.g., 'A futuristic cityscape at sunset'", + title="Prompt", + ) + replicate_model_name: ReplicateFluxModelName = SchemaField( + description="The name of the Image Generation Model, i.e Flux Schnell", + default=ReplicateFluxModelName.FLUX_SCHNELL, + title="Image Generation Model", + advanced=False, + ) + seed: int | None = SchemaField( + description="Random seed. Set for reproducible generation", + default=None, + title="Seed", + ) + steps: int = SchemaField( + description="Number of diffusion steps", + default=25, + title="Steps", + ) + guidance: float = SchemaField( + description=( + "Controls the balance between adherence to the text prompt and image quality/diversity. " + "Higher values make the output more closely match the prompt but may reduce overall image quality." + ), + default=3, + title="Guidance", + ) + interval: float = SchemaField( + description=( + "Interval is a setting that increases the variance in possible outputs. " + "Setting this value low will ensure strong prompt following with more consistent outputs." + ), + default=2, + title="Interval", + ) + aspect_ratio: str = SchemaField( + description="Aspect ratio for the generated image", + default="1:1", + title="Aspect Ratio", + placeholder="Choose from: 1:1, 16:9, 2:3, 3:2, 4:5, 5:4, 9:16", + ) + output_format: ImageType = SchemaField( + description="File format of the output image", + default=ImageType.WEBP, + title="Output Format", + ) + output_quality: int = SchemaField( + description=( + "Quality when saving the output images, from 0 to 100. " + "Not relevant for .png outputs" + ), + default=80, + title="Output Quality", + ) + safety_tolerance: int = SchemaField( + description="Safety tolerance, 1 is most strict and 5 is most permissive", + default=2, + title="Safety Tolerance", + ) + + class Output(BlockSchema): + result: str = SchemaField(description="Generated output") + error: str = SchemaField(description="Error message if the model run failed") + + def __init__(self): + super().__init__( + id="90f8c45e-e983-4644-aa0b-b4ebe2f531bc", + description="This block runs Flux models on Replicate with advanced settings.", + categories={BlockCategory.AI, BlockCategory.MULTIMEDIA}, + input_schema=ReplicateFluxAdvancedModelBlock.Input, + output_schema=ReplicateFluxAdvancedModelBlock.Output, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "replicate_model_name": ReplicateFluxModelName.FLUX_SCHNELL, + "prompt": "A beautiful landscape painting of a serene lake at sunrise", + "seed": None, + "steps": 25, + "guidance": 3.0, + "interval": 2.0, + "aspect_ratio": "1:1", + "output_format": ImageType.PNG, + "output_quality": 80, + "safety_tolerance": 2, + }, + test_output=[ + ( + "result", + "https://replicate.com/output/generated-image-url.jpg", + ), + ], + test_mock={ + "run_model": lambda api_key, model_name, prompt, seed, steps, guidance, interval, aspect_ratio, output_format, output_quality, safety_tolerance: "https://replicate.com/output/generated-image-url.jpg", + }, + test_credentials=TEST_CREDENTIALS, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + # If the seed is not provided, generate a random seed + seed = input_data.seed + if seed is None: + seed = int.from_bytes(os.urandom(4), "big") + + # Run the model using the provided inputs + result = await self.run_model( + api_key=credentials.api_key, + model_name=input_data.replicate_model_name.api_name, + prompt=input_data.prompt, + seed=seed, + steps=input_data.steps, + guidance=input_data.guidance, + interval=input_data.interval, + aspect_ratio=input_data.aspect_ratio, + output_format=input_data.output_format, + output_quality=input_data.output_quality, + safety_tolerance=input_data.safety_tolerance, + ) + yield "result", result + + async def run_model( + self, + api_key: SecretStr, + model_name, + prompt, + seed, + steps, + guidance, + interval, + aspect_ratio, + output_format, + output_quality, + safety_tolerance, + ): + # Initialize Replicate client with the API key + client = ReplicateClient(api_token=api_key.get_secret_value()) + + # Run the model with additional parameters + output: ReplicateOutputs = await client.async_run( # type: ignore This is because they changed the return type, and didn't update the type hint! It should be overloaded depending on the value of `use_file_output` to `FileOutput | list[FileOutput]` but it's `Any | Iterator[Any]` + f"{model_name}", + input={ + "prompt": prompt, + "seed": seed, + "steps": steps, + "guidance": guidance, + "interval": interval, + "aspect_ratio": aspect_ratio, + "output_format": output_format, + "output_quality": output_quality, + "safety_tolerance": safety_tolerance, + }, + wait=False, # don't arbitrarily return data:octect/stream or sometimes url depending on the model???? what is this api + ) + + result = extract_result(output) + + return result diff --git a/autogpt_platform/backend/backend/blocks/replicate/replicate_block.py b/autogpt_platform/backend/backend/blocks/replicate/replicate_block.py new file mode 100644 index 000000000000..ca0477788ad4 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/replicate/replicate_block.py @@ -0,0 +1,133 @@ +import logging +from typing import Optional + +from pydantic import SecretStr +from replicate.client import Client as ReplicateClient + +from backend.blocks.replicate._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + ReplicateCredentialsInput, +) +from backend.blocks.replicate._helper import ReplicateOutputs, extract_result +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import APIKeyCredentials, CredentialsField, SchemaField + +logger = logging.getLogger(__name__) + + +class ReplicateModelBlock(Block): + """ + Block for running any Replicate model with custom inputs. + + This block allows you to: + - Use any public Replicate model + - Pass custom inputs as a dictionary + - Specify model versions + - Get structured outputs with prediction metadata + """ + + class Input(BlockSchema): + credentials: ReplicateCredentialsInput = CredentialsField( + description="Enter your Replicate API key to access the model API. You can obtain an API key from https://replicate.com/account/api-tokens.", + ) + model_name: str = SchemaField( + description="The Replicate model name (format: 'owner/model-name')", + placeholder="stability-ai/stable-diffusion", + advanced=False, + ) + model_inputs: dict[str, str | int] = SchemaField( + default={}, + description="Dictionary of inputs to pass to the model", + placeholder='{"prompt": "a beautiful landscape", "num_outputs": 1}', + advanced=False, + ) + version: Optional[str] = SchemaField( + default=None, + description="Specific version hash of the model (optional)", + placeholder="db21e45d3f7023abc2a46ee38a23973f6dce16bb082a930b0c49861f96d1e5bf", + advanced=True, + ) + + class Output(BlockSchema): + result: str = SchemaField(description="The output from the Replicate model") + status: str = SchemaField(description="Status of the prediction") + model_name: str = SchemaField(description="Name of the model used") + error: str = SchemaField(description="Error message if any", default="") + + def __init__(self): + super().__init__( + id="c40d75a2-d0ea-44c9-a4f6-634bb3bdab1a", + description="Run Replicate models synchronously", + categories={BlockCategory.AI}, + input_schema=ReplicateModelBlock.Input, + output_schema=ReplicateModelBlock.Output, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "model_name": "meta/llama-2-7b-chat", + "model_inputs": {"prompt": "Hello, world!", "max_new_tokens": 50}, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("result", str), + ("status", str), + ("model_name", str), + ], + test_mock={ + "run_model": lambda model_ref, model_inputs, api_key: ( + "Mock response from Replicate model" + ) + }, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + """ + Execute the Replicate model with the provided inputs. + + Args: + input_data: The input data containing model name and inputs + credentials: The API credentials + + Yields: + BlockOutput containing the model results and metadata + """ + try: + if input_data.version: + model_ref = f"{input_data.model_name}:{input_data.version}" + else: + model_ref = input_data.model_name + logger.debug(f"Running Replicate model: {model_ref}") + result = await self.run_model( + model_ref, input_data.model_inputs, credentials.api_key + ) + yield "result", result + yield "status", "succeeded" + yield "model_name", input_data.model_name + except Exception as e: + error_msg = f"Unexpected error running Replicate model: {str(e)}" + logger.error(error_msg) + raise RuntimeError(error_msg) + + async def run_model(self, model_ref: str, model_inputs: dict, api_key: SecretStr): + """ + Run the Replicate model. This method can be mocked for testing. + + Args: + model_ref: The model reference (e.g., "owner/model-name:version") + model_inputs: The inputs to pass to the model + api_key: The Replicate API key as SecretStr + + Returns: + Tuple of (result, prediction_id) + """ + api_key_str = api_key.get_secret_value() + client = ReplicateClient(api_token=api_key_str) + output: ReplicateOutputs = await client.async_run( + model_ref, input=model_inputs, wait=False + ) # type: ignore they suck at typing + + result = extract_result(output) + + return result diff --git a/autogpt_platform/backend/backend/blocks/replicate_flux_advanced.py b/autogpt_platform/backend/backend/blocks/replicate_flux_advanced.py deleted file mode 100644 index c913ead132e6..000000000000 --- a/autogpt_platform/backend/backend/blocks/replicate_flux_advanced.py +++ /dev/null @@ -1,237 +0,0 @@ -import os -from enum import Enum -from typing import Literal - -import replicate -from pydantic import SecretStr -from replicate.helpers import FileOutput - -from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema -from backend.data.model import ( - APIKeyCredentials, - CredentialsField, - CredentialsMetaInput, - SchemaField, -) -from backend.integrations.providers import ProviderName - -TEST_CREDENTIALS = APIKeyCredentials( - id="01234567-89ab-cdef-0123-456789abcdef", - provider="replicate", - api_key=SecretStr("mock-replicate-api-key"), - title="Mock Replicate API key", - expires_at=None, -) -TEST_CREDENTIALS_INPUT = { - "provider": TEST_CREDENTIALS.provider, - "id": TEST_CREDENTIALS.id, - "type": TEST_CREDENTIALS.type, - "title": TEST_CREDENTIALS.type, -} - - -# Model name enum -class ReplicateFluxModelName(str, Enum): - FLUX_SCHNELL = ("Flux Schnell",) - FLUX_PRO = ("Flux Pro",) - FLUX_PRO1_1 = ("Flux Pro 1.1",) - - @property - def api_name(self): - api_names = { - ReplicateFluxModelName.FLUX_SCHNELL: "black-forest-labs/flux-schnell", - ReplicateFluxModelName.FLUX_PRO: "black-forest-labs/flux-pro", - ReplicateFluxModelName.FLUX_PRO1_1: "black-forest-labs/flux-1.1-pro", - } - return api_names[self] - - -# Image type Enum -class ImageType(str, Enum): - WEBP = "webp" - JPG = "jpg" - PNG = "png" - - -class ReplicateFluxAdvancedModelBlock(Block): - class Input(BlockSchema): - credentials: CredentialsMetaInput[ - Literal[ProviderName.REPLICATE], Literal["api_key"] - ] = CredentialsField( - description="The Replicate integration can be used with " - "any API key with sufficient permissions for the blocks it is used on.", - ) - prompt: str = SchemaField( - description="Text prompt for image generation", - placeholder="e.g., 'A futuristic cityscape at sunset'", - title="Prompt", - ) - replicate_model_name: ReplicateFluxModelName = SchemaField( - description="The name of the Image Generation Model, i.e Flux Schnell", - default=ReplicateFluxModelName.FLUX_SCHNELL, - title="Image Generation Model", - advanced=False, - ) - seed: int | None = SchemaField( - description="Random seed. Set for reproducible generation", - default=None, - title="Seed", - ) - steps: int = SchemaField( - description="Number of diffusion steps", - default=25, - title="Steps", - ) - guidance: float = SchemaField( - description=( - "Controls the balance between adherence to the text prompt and image quality/diversity. " - "Higher values make the output more closely match the prompt but may reduce overall image quality." - ), - default=3, - title="Guidance", - ) - interval: float = SchemaField( - description=( - "Interval is a setting that increases the variance in possible outputs. " - "Setting this value low will ensure strong prompt following with more consistent outputs." - ), - default=2, - title="Interval", - ) - aspect_ratio: str = SchemaField( - description="Aspect ratio for the generated image", - default="1:1", - title="Aspect Ratio", - placeholder="Choose from: 1:1, 16:9, 2:3, 3:2, 4:5, 5:4, 9:16", - ) - output_format: ImageType = SchemaField( - description="File format of the output image", - default=ImageType.WEBP, - title="Output Format", - ) - output_quality: int = SchemaField( - description=( - "Quality when saving the output images, from 0 to 100. " - "Not relevant for .png outputs" - ), - default=80, - title="Output Quality", - ) - safety_tolerance: int = SchemaField( - description="Safety tolerance, 1 is most strict and 5 is most permissive", - default=2, - title="Safety Tolerance", - ) - - class Output(BlockSchema): - result: str = SchemaField(description="Generated output") - error: str = SchemaField(description="Error message if the model run failed") - - def __init__(self): - super().__init__( - id="90f8c45e-e983-4644-aa0b-b4ebe2f531bc", - description="This block runs Flux models on Replicate with advanced settings.", - categories={BlockCategory.AI}, - input_schema=ReplicateFluxAdvancedModelBlock.Input, - output_schema=ReplicateFluxAdvancedModelBlock.Output, - test_input={ - "credentials": TEST_CREDENTIALS_INPUT, - "replicate_model_name": ReplicateFluxModelName.FLUX_SCHNELL, - "prompt": "A beautiful landscape painting of a serene lake at sunrise", - "seed": None, - "steps": 25, - "guidance": 3.0, - "interval": 2.0, - "aspect_ratio": "1:1", - "output_format": ImageType.PNG, - "output_quality": 80, - "safety_tolerance": 2, - }, - test_output=[ - ( - "result", - "https://replicate.com/output/generated-image-url.jpg", - ), - ], - test_mock={ - "run_model": lambda api_key, model_name, prompt, seed, steps, guidance, interval, aspect_ratio, output_format, output_quality, safety_tolerance: "https://replicate.com/output/generated-image-url.jpg", - }, - test_credentials=TEST_CREDENTIALS, - ) - - def run( - self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs - ) -> BlockOutput: - # If the seed is not provided, generate a random seed - seed = input_data.seed - if seed is None: - seed = int.from_bytes(os.urandom(4), "big") - - # Run the model using the provided inputs - result = self.run_model( - api_key=credentials.api_key, - model_name=input_data.replicate_model_name.api_name, - prompt=input_data.prompt, - seed=seed, - steps=input_data.steps, - guidance=input_data.guidance, - interval=input_data.interval, - aspect_ratio=input_data.aspect_ratio, - output_format=input_data.output_format, - output_quality=input_data.output_quality, - safety_tolerance=input_data.safety_tolerance, - ) - yield "result", result - - def run_model( - self, - api_key: SecretStr, - model_name, - prompt, - seed, - steps, - guidance, - interval, - aspect_ratio, - output_format, - output_quality, - safety_tolerance, - ): - # Initialize Replicate client with the API key - client = replicate.Client(api_token=api_key.get_secret_value()) - - # Run the model with additional parameters - output: FileOutput | list[FileOutput] = client.run( # type: ignore This is because they changed the return type, and didn't update the type hint! It should be overloaded depending on the value of `use_file_output` to `FileOutput | list[FileOutput]` but it's `Any | Iterator[Any]` - f"{model_name}", - input={ - "prompt": prompt, - "seed": seed, - "steps": steps, - "guidance": guidance, - "interval": interval, - "aspect_ratio": aspect_ratio, - "output_format": output_format, - "output_quality": output_quality, - "safety_tolerance": safety_tolerance, - }, - wait=False, # don't arbitrarily return data:octect/stream or sometimes url depending on the model???? what is this api - ) - - # Check if output is a list or a string and extract accordingly; otherwise, assign a default message - if isinstance(output, list) and len(output) > 0: - if isinstance(output[0], FileOutput): - result_url = output[0].url # If output is a list, get the first element - else: - result_url = output[ - 0 - ] # If output is a list and not a FileOutput, get the first element. Should never happen, but just in case. - elif isinstance(output, FileOutput): - result_url = output.url # If output is a FileOutput, use the url - elif isinstance(output, str): - result_url = output # If output is a string (for some reason due to their janky type hinting), use it directly - else: - result_url = ( - "No output received" # Fallback message if output is not as expected - ) - - return result_url diff --git a/autogpt_platform/backend/backend/blocks/rss.py b/autogpt_platform/backend/backend/blocks/rss.py index 9a5a17ebeeda..751506fcb817 100644 --- a/autogpt_platform/backend/backend/blocks/rss.py +++ b/autogpt_platform/backend/backend/blocks/rss.py @@ -1,4 +1,4 @@ -import time +import asyncio from datetime import datetime, timedelta, timezone from typing import Any @@ -40,6 +40,7 @@ class Input(BlockSchema): class Output(BlockSchema): entry: RSSEntry = SchemaField(description="The RSS item") + entries: list[RSSEntry] = SchemaField(description="List of all RSS entries") def __init__(self): super().__init__( @@ -66,6 +67,21 @@ def __init__(self): categories=["Technology", "News"], ), ), + ( + "entries", + [ + RSSEntry( + title="Example RSS Item", + link="https://example.com/article", + description="This is an example RSS item description.", + pub_date=datetime( + 2023, 6, 23, 12, 30, 0, tzinfo=timezone.utc + ), + author="John Doe", + categories=["Technology", "News"], + ), + ], + ), ], test_mock={ "parse_feed": lambda *args, **kwargs: { @@ -87,7 +103,7 @@ def __init__(self): def parse_feed(url: str) -> dict[str, Any]: return feedparser.parse(url) # type: ignore - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: keep_going = True start_time = datetime.now(timezone.utc) - timedelta( minutes=input_data.time_period @@ -96,21 +112,22 @@ def run(self, input_data: Input, **kwargs) -> BlockOutput: keep_going = input_data.run_continuously feed = self.parse_feed(input_data.rss_url) + all_entries = [] for entry in feed["entries"]: pub_date = datetime(*entry["published_parsed"][:6], tzinfo=timezone.utc) if pub_date > start_time: - yield ( - "entry", - RSSEntry( - title=entry["title"], - link=entry["link"], - description=entry.get("summary", ""), - pub_date=pub_date, - author=entry.get("author", ""), - categories=[tag["term"] for tag in entry.get("tags", [])], - ), + rss_entry = RSSEntry( + title=entry["title"], + link=entry["link"], + description=entry.get("summary", ""), + pub_date=pub_date, + author=entry.get("author", ""), + categories=[tag["term"] for tag in entry.get("tags", [])], ) + all_entries.append(rss_entry) + yield "entry", rss_entry - time.sleep(input_data.polling_rate) + yield "entries", all_entries + await asyncio.sleep(input_data.polling_rate) diff --git a/autogpt_platform/backend/backend/blocks/sampling.py b/autogpt_platform/backend/backend/blocks/sampling.py index d2257db06f68..ffd509ff75d0 100644 --- a/autogpt_platform/backend/backend/blocks/sampling.py +++ b/autogpt_platform/backend/backend/blocks/sampling.py @@ -93,7 +93,7 @@ def __init__(self): ) self.accumulated_data = [] - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: if input_data.accumulate: if isinstance(input_data.data, dict): self.accumulated_data.append(input_data.data) diff --git a/autogpt_platform/backend/backend/blocks/screenshotone.py b/autogpt_platform/backend/backend/blocks/screenshotone.py new file mode 100644 index 000000000000..5ca97e77f78a --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/screenshotone.py @@ -0,0 +1,188 @@ +from base64 import b64encode +from enum import Enum +from typing import Literal + +from pydantic import SecretStr + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import ( + APIKeyCredentials, + CredentialsField, + CredentialsMetaInput, + SchemaField, +) +from backend.integrations.providers import ProviderName +from backend.util.file import MediaFileType, store_media_file +from backend.util.request import Requests + + +class Format(str, Enum): + PNG = "png" + JPEG = "jpeg" + WEBP = "webp" + + +class ScreenshotWebPageBlock(Block): + """Block for taking screenshots using ScreenshotOne API""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput[ + Literal[ProviderName.SCREENSHOTONE], Literal["api_key"] + ] = CredentialsField(description="The ScreenshotOne API key") + url: str = SchemaField( + description="URL of the website to screenshot", + placeholder="https://example.com", + ) + viewport_width: int = SchemaField( + description="Width of the viewport in pixels", default=1920 + ) + viewport_height: int = SchemaField( + description="Height of the viewport in pixels", default=1080 + ) + full_page: bool = SchemaField( + description="Whether to capture the full page length", default=False + ) + format: Format = SchemaField( + description="Output format (png, jpeg, webp)", default=Format.PNG + ) + block_ads: bool = SchemaField(description="Whether to block ads", default=True) + block_cookie_banners: bool = SchemaField( + description="Whether to block cookie banners", default=True + ) + block_chats: bool = SchemaField( + description="Whether to block chat widgets", default=True + ) + cache: bool = SchemaField( + description="Whether to enable caching", default=False + ) + + class Output(BlockSchema): + image: MediaFileType = SchemaField(description="The screenshot image data") + error: str = SchemaField(description="Error message if the screenshot failed") + + def __init__(self): + super().__init__( + id="3a7c4b8d-6e2f-4a5d-b9c1-f8d23c5a9b0e", # Generated UUID + description="Takes a screenshot of a specified website using ScreenshotOne API", + categories={BlockCategory.DATA}, + input_schema=ScreenshotWebPageBlock.Input, + output_schema=ScreenshotWebPageBlock.Output, + test_input={ + "url": "https://example.com", + "viewport_width": 1920, + "viewport_height": 1080, + "full_page": False, + "format": "png", + "block_ads": True, + "block_cookie_banners": True, + "block_chats": True, + "cache": False, + "credentials": { + "provider": "screenshotone", + "type": "api_key", + "id": "test-id", + "title": "Test API Key", + }, + }, + test_credentials=APIKeyCredentials( + id="test-id", + provider="screenshotone", + api_key=SecretStr("test-key"), + title="Test API Key", + expires_at=None, + ), + test_output=[ + ( + "image", + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAB5JREFUOE9jZPjP8J+BAsA4agDDaBgwjIYBw7AIAwCV5B/xAsMbygAAAABJRU5ErkJggg==", + ), + ], + test_mock={ + "take_screenshot": lambda *args, **kwargs: { + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAAXNSR0IArs4c6QAAAB5JREFUOE9jZPjP8J+BAsA4agDDaBgwjIYBw7AIAwCV5B/xAsMbygAAAABJRU5ErkJggg==", + } + }, + ) + + @staticmethod + async def take_screenshot( + credentials: APIKeyCredentials, + graph_exec_id: str, + user_id: str, + url: str, + viewport_width: int, + viewport_height: int, + full_page: bool, + format: Format, + block_ads: bool, + block_cookie_banners: bool, + block_chats: bool, + cache: bool, + ) -> dict: + """ + Takes a screenshot using the ScreenshotOne API + """ + api = Requests() + + # Build API parameters + params = { + "url": url, + "viewport_width": viewport_width, + "viewport_height": viewport_height, + "full_page": str(full_page).lower(), + "format": format.value, + "block_ads": str(block_ads).lower(), + "block_cookie_banners": str(block_cookie_banners).lower(), + "block_chats": str(block_chats).lower(), + "cache": str(cache).lower(), + } + + # Make the API request + # Use header-based authentication instead of query parameter + headers = { + "X-Access-Key": credentials.api_key.get_secret_value(), + } + + response = await api.get( + "https://api.screenshotone.com/take", params=params, headers=headers + ) + content = response.content + + return { + "image": await store_media_file( + graph_exec_id=graph_exec_id, + file=MediaFileType( + f"data:image/{format.value};base64,{b64encode(content).decode('utf-8')}" + ), + user_id=user_id, + return_content=True, + ) + } + + async def run( + self, + input_data: Input, + *, + credentials: APIKeyCredentials, + graph_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + try: + screenshot_data = await self.take_screenshot( + credentials=credentials, + graph_exec_id=graph_exec_id, + user_id=user_id, + url=input_data.url, + viewport_width=input_data.viewport_width, + viewport_height=input_data.viewport_height, + full_page=input_data.full_page, + format=input_data.format, + block_ads=input_data.block_ads, + block_cookie_banners=input_data.block_cookie_banners, + block_chats=input_data.block_chats, + cache=input_data.cache, + ) + yield "image", screenshot_data["image"] + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/search.py b/autogpt_platform/backend/backend/blocks/search.py index 633ad3109113..51eadf215e5f 100644 --- a/autogpt_platform/backend/backend/blocks/search.py +++ b/autogpt_platform/backend/backend/blocks/search.py @@ -36,10 +36,10 @@ def __init__(self): test_mock={"get_request": lambda url, json: {"extract": "summary content"}}, ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: topic = input_data.topic url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{topic}" - response = self.get_request(url, json=True) + response = await self.get_request(url, json=True) if "extract" not in response: raise RuntimeError(f"Unable to parse Wikipedia response: {response}") yield "summary", response["extract"] @@ -113,14 +113,14 @@ def __init__(self): test_credentials=TEST_CREDENTIALS, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: units = "metric" if input_data.use_celsius else "imperial" api_key = credentials.api_key location = input_data.location url = f"http://api.openweathermap.org/data/2.5/weather?q={quote(location)}&appid={api_key}&units={units}" - weather_data = self.get_request(url, json=True) + weather_data = await self.get_request(url, json=True) if "main" in weather_data and "weather" in weather_data: yield "temperature", str(weather_data["main"]["temp"]) diff --git a/autogpt_platform/backend/backend/blocks/slant3d/base.py b/autogpt_platform/backend/backend/blocks/slant3d/base.py index d5d1681e1d43..e368a1b45149 100644 --- a/autogpt_platform/backend/backend/blocks/slant3d/base.py +++ b/autogpt_platform/backend/backend/blocks/slant3d/base.py @@ -1,7 +1,7 @@ from typing import Any, Dict from backend.data.block import Block -from backend.util.request import requests +from backend.util.request import Requests from ._api import Color, CustomerDetails, OrderItem, Profile @@ -14,20 +14,25 @@ class Slant3DBlockBase(Block): def _get_headers(self, api_key: str) -> Dict[str, str]: return {"api-key": api_key, "Content-Type": "application/json"} - def _make_request(self, method: str, endpoint: str, api_key: str, **kwargs) -> Dict: + async def _make_request( + self, method: str, endpoint: str, api_key: str, **kwargs + ) -> Dict: url = f"{self.BASE_URL}/{endpoint}" - response = requests.request( + response = await Requests().request( method=method, url=url, headers=self._get_headers(api_key), **kwargs ) + resp = response.json() if not response.ok: - error_msg = response.json().get("error", "Unknown error") + error_msg = resp.get("error", "Unknown error") raise RuntimeError(f"API request failed: {error_msg}") - return response.json() + return resp - def _check_valid_color(self, profile: Profile, color: Color, api_key: str) -> str: - response = self._make_request( + async def _check_valid_color( + self, profile: Profile, color: Color, api_key: str + ) -> str: + response = await self._make_request( "GET", "filament", api_key, @@ -48,10 +53,12 @@ def _check_valid_color(self, profile: Profile, color: Color, api_key: str) -> st ) return color_tag - def _convert_to_color(self, profile: Profile, color: Color, api_key: str) -> str: - return self._check_valid_color(profile, color, api_key) + async def _convert_to_color( + self, profile: Profile, color: Color, api_key: str + ) -> str: + return await self._check_valid_color(profile, color, api_key) - def _format_order_data( + async def _format_order_data( self, customer: CustomerDetails, order_number: str, @@ -61,6 +68,7 @@ def _format_order_data( """Helper function to format order data for API requests""" orders = [] for item in items: + color_tag = await self._convert_to_color(item.profile, item.color, api_key) order_data = { "email": customer.email, "phone": customer.phone, @@ -85,9 +93,7 @@ def _format_order_data( "order_quantity": item.quantity, "order_image_url": "", "order_sku": "NOT_USED", - "order_item_color": self._convert_to_color( - item.profile, item.color, api_key - ), + "order_item_color": color_tag, "profile": item.profile.value, } orders.append(order_data) diff --git a/autogpt_platform/backend/backend/blocks/slant3d/filament.py b/autogpt_platform/backend/backend/blocks/slant3d/filament.py index c232c2ba8da1..0659a45561df 100644 --- a/autogpt_platform/backend/backend/blocks/slant3d/filament.py +++ b/autogpt_platform/backend/backend/blocks/slant3d/filament.py @@ -72,11 +72,11 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: try: - result = self._make_request( + result = await self._make_request( "GET", "filament", credentials.api_key.get_secret_value() ) yield "filaments", result["filaments"] diff --git a/autogpt_platform/backend/backend/blocks/slant3d/order.py b/autogpt_platform/backend/backend/blocks/slant3d/order.py index a1a342a98e0b..43a58024687d 100644 --- a/autogpt_platform/backend/backend/blocks/slant3d/order.py +++ b/autogpt_platform/backend/backend/blocks/slant3d/order.py @@ -1,12 +1,9 @@ import uuid from typing import List -import requests as baserequests - from backend.data.block import BlockOutput, BlockSchema from backend.data.model import APIKeyCredentials, SchemaField -from backend.util import settings -from backend.util.settings import BehaveAs +from backend.util.settings import BehaveAs, Settings from ._api import ( TEST_CREDENTIALS, @@ -18,6 +15,8 @@ ) from .base import Slant3DBlockBase +settings = Settings() + class Slant3DCreateOrderBlock(Slant3DBlockBase): """Block for creating new orders""" @@ -76,17 +75,17 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: try: - order_data = self._format_order_data( + order_data = await self._format_order_data( input_data.customer, input_data.order_number, input_data.items, credentials.api_key.get_secret_value(), ) - result = self._make_request( + result = await self._make_request( "POST", "order", credentials.api_key.get_secret_value(), json=order_data ) yield "order_id", result["orderId"] @@ -162,28 +161,24 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: - order_data = self._format_order_data( + order_data = await self._format_order_data( input_data.customer, input_data.order_number, input_data.items, credentials.api_key.get_secret_value(), ) - try: - result = self._make_request( - "POST", - "order/estimate", - credentials.api_key.get_secret_value(), - json=order_data, - ) - yield "total_price", result["totalPrice"] - yield "shipping_cost", result["shippingCost"] - yield "printing_cost", result["printingCost"] - except baserequests.HTTPError as e: - yield "error", str(f"Error estimating order: {e} {e.response.text}") - raise + result = await self._make_request( + "POST", + "order/estimate", + credentials.api_key.get_secret_value(), + json=order_data, + ) + yield "total_price", result["totalPrice"] + yield "shipping_cost", result["shippingCost"] + yield "printing_cost", result["printingCost"] class Slant3DEstimateShippingBlock(Slant3DBlockBase): @@ -246,17 +241,17 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: try: - order_data = self._format_order_data( + order_data = await self._format_order_data( input_data.customer, input_data.order_number, input_data.items, credentials.api_key.get_secret_value(), ) - result = self._make_request( + result = await self._make_request( "POST", "order/estimateShipping", credentials.api_key.get_secret_value(), @@ -286,7 +281,7 @@ def __init__(self): input_schema=self.Input, output_schema=self.Output, # This block is disabled for cloud hosted because it allows access to all orders for the account - disabled=settings.Settings().config.behave_as == BehaveAs.CLOUD, + disabled=settings.config.behave_as == BehaveAs.CLOUD, test_input={"credentials": TEST_CREDENTIALS_INPUT}, test_credentials=TEST_CREDENTIALS, test_output=[ @@ -312,11 +307,11 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: try: - result = self._make_request( + result = await self._make_request( "GET", "order", credentials.api_key.get_secret_value() ) yield "orders", [str(order["orderId"]) for order in result["ordersData"]] @@ -359,11 +354,11 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: try: - result = self._make_request( + result = await self._make_request( "GET", f"order/{input_data.order_id}/get-tracking", credentials.api_key.get_secret_value(), @@ -403,11 +398,11 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: try: - result = self._make_request( + result = await self._make_request( "DELETE", f"order/{input_data.order_id}", credentials.api_key.get_secret_value(), diff --git a/autogpt_platform/backend/backend/blocks/slant3d/slicing.py b/autogpt_platform/backend/backend/blocks/slant3d/slicing.py index 1b868efc9edd..6abe3045acbe 100644 --- a/autogpt_platform/backend/backend/blocks/slant3d/slicing.py +++ b/autogpt_platform/backend/backend/blocks/slant3d/slicing.py @@ -44,11 +44,11 @@ def __init__(self): }, ) - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: try: - result = self._make_request( + result = await self._make_request( "POST", "slicer", credentials.api_key.get_secret_value(), diff --git a/autogpt_platform/backend/backend/blocks/slant3d/webhook.py b/autogpt_platform/backend/backend/blocks/slant3d/webhook.py index 5726790f99f2..22f87b468d86 100644 --- a/autogpt_platform/backend/backend/blocks/slant3d/webhook.py +++ b/autogpt_platform/backend/backend/blocks/slant3d/webhook.py @@ -8,8 +8,8 @@ BlockWebhookConfig, ) from backend.data.model import SchemaField -from backend.util import settings -from backend.util.settings import AppEnvironment, BehaveAs +from backend.integrations.providers import ProviderName +from backend.util.settings import AppEnvironment, BehaveAs, Settings from ._api import ( TEST_CREDENTIALS, @@ -18,6 +18,8 @@ Slant3DCredentialsInput, ) +settings = Settings() + class Slant3DTriggerBase: """Base class for Slant3D webhook triggers""" @@ -25,7 +27,7 @@ class Slant3DTriggerBase: class Input(BlockSchema): credentials: Slant3DCredentialsInput = Slant3DCredentialsField() # Webhook URL is handled by the webhook system - payload: dict = SchemaField(hidden=True, default={}) + payload: dict = SchemaField(hidden=True, default_factory=dict) class Output(BlockSchema): payload: dict = SchemaField( @@ -36,7 +38,7 @@ class Output(BlockSchema): description="Error message if payload processing failed" ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: yield "payload", input_data.payload yield "order_id", input_data.payload["orderId"] @@ -75,14 +77,14 @@ def __init__(self): ), # All webhooks are currently subscribed to for all orders. This works for self hosted, but not for cloud hosted prod disabled=( - settings.Settings().config.behave_as == BehaveAs.CLOUD - and settings.Settings().config.app_env != AppEnvironment.LOCAL + settings.config.behave_as == BehaveAs.CLOUD + and settings.config.app_env != AppEnvironment.LOCAL ), categories={BlockCategory.DEVELOPER_TOOLS}, input_schema=self.Input, output_schema=self.Output, webhook_config=BlockWebhookConfig( - provider="slant3d", + provider=ProviderName.SLANT3D, webhook_type="orders", # Only one type for now resource_format="", # No resource format needed event_filter_input="events", @@ -116,8 +118,9 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore - yield from super().run(input_data, **kwargs) + async def run(self, input_data: Input, **kwargs) -> BlockOutput: # type: ignore + async for name, value in super().run(input_data, **kwargs): + yield name, value # Extract and normalize values from the payload yield "status", input_data.payload["status"] diff --git a/autogpt_platform/backend/backend/blocks/smart_decision_maker.py b/autogpt_platform/backend/backend/blocks/smart_decision_maker.py new file mode 100644 index 000000000000..9ae41d9c9382 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/smart_decision_maker.py @@ -0,0 +1,583 @@ +import logging +import re +from collections import Counter +from typing import TYPE_CHECKING, Any + +import backend.blocks.llm as llm +from backend.blocks.agent import AgentExecutorBlock +from backend.data.block import ( + Block, + BlockCategory, + BlockInput, + BlockOutput, + BlockSchema, + BlockType, +) +from backend.data.model import NodeExecutionStats, SchemaField +from backend.util import json +from backend.util.clients import get_database_manager_async_client + +if TYPE_CHECKING: + from backend.data.graph import Link, Node + +logger = logging.getLogger(__name__) + + +def _get_tool_requests(entry: dict[str, Any]) -> list[str]: + """ + Return a list of tool_call_ids if the entry is a tool request. + Supports both OpenAI and Anthropics formats. + """ + tool_call_ids = [] + if entry.get("role") != "assistant": + return tool_call_ids + + # OpenAI: check for tool_calls in the entry. + calls = entry.get("tool_calls") + if isinstance(calls, list): + for call in calls: + if tool_id := call.get("id"): + tool_call_ids.append(tool_id) + + # Anthropics: check content items for tool_use type. + content = entry.get("content") + if isinstance(content, list): + for item in content: + if item.get("type") != "tool_use": + continue + if tool_id := item.get("id"): + tool_call_ids.append(tool_id) + + return tool_call_ids + + +def _get_tool_responses(entry: dict[str, Any]) -> list[str]: + """ + Return a list of tool_call_ids if the entry is a tool response. + Supports both OpenAI and Anthropics formats. + """ + tool_call_ids: list[str] = [] + + # OpenAI: a tool response message with role "tool" and key "tool_call_id". + if entry.get("role") == "tool": + if tool_call_id := entry.get("tool_call_id"): + tool_call_ids.append(str(tool_call_id)) + + # Anthropics: check content items for tool_result type. + if entry.get("role") == "user": + content = entry.get("content") + if isinstance(content, list): + for item in content: + if item.get("type") != "tool_result": + continue + if tool_call_id := item.get("tool_use_id"): + tool_call_ids.append(tool_call_id) + + return tool_call_ids + + +def _create_tool_response(call_id: str, output: Any) -> dict[str, Any]: + """ + Create a tool response message for either OpenAI or Anthropics, + based on the tool_id format. + """ + content = output if isinstance(output, str) else json.dumps(output) + + # Anthropics format: tool IDs typically start with "toolu_" + if call_id.startswith("toolu_"): + return { + "role": "user", + "type": "message", + "content": [ + {"tool_use_id": call_id, "type": "tool_result", "content": content} + ], + } + + # OpenAI format: tool IDs typically start with "call_". + # Or default fallback (if the tool_id doesn't match any known prefix) + return {"role": "tool", "tool_call_id": call_id, "content": content} + + +def get_pending_tool_calls(conversation_history: list[Any]) -> dict[str, int]: + """ + All the tool calls entry in the conversation history requires a response. + This function returns the pending tool calls that has not generated an output yet. + + Return: dict[str, int] - A dictionary of pending tool call IDs with their count. + """ + pending_calls = Counter() + for history in conversation_history: + for call_id in _get_tool_requests(history): + pending_calls[call_id] += 1 + + for call_id in _get_tool_responses(history): + pending_calls[call_id] -= 1 + + return {call_id: count for call_id, count in pending_calls.items() if count > 0} + + +class SmartDecisionMakerBlock(Block): + """ + A block that uses a language model to make smart decisions based on a given prompt. + """ + + class Input(BlockSchema): + prompt: str = SchemaField( + description="The prompt to send to the language model.", + placeholder="Enter your prompt here...", + ) + model: llm.LlmModel = SchemaField( + title="LLM Model", + default=llm.LlmModel.GPT4O, + description="The language model to use for answering the prompt.", + advanced=False, + ) + credentials: llm.AICredentials = llm.AICredentialsField() + multiple_tool_calls: bool = SchemaField( + title="Multiple Tool Calls", + default=False, + description="Whether to allow multiple tool calls in a single response.", + advanced=True, + ) + sys_prompt: str = SchemaField( + title="System Prompt", + default="Thinking carefully step by step decide which function to call. " + "Always choose a function call from the list of function signatures, " + "and always provide the complete argument provided with the type " + "matching the required jsonschema signature, no missing argument is allowed. " + "If you have already completed the task objective, you can end the task " + "by providing the end result of your work as a finish message. " + "Function parameters that has no default value and not optional typed has to be provided. ", + description="The system prompt to provide additional context to the model.", + ) + conversation_history: list[dict] = SchemaField( + default_factory=list, + description="The conversation history to provide context for the prompt.", + ) + last_tool_output: Any = SchemaField( + default=None, + description="The output of the last tool that was called.", + ) + retry: int = SchemaField( + title="Retry Count", + default=3, + description="Number of times to retry the LLM call if the response does not match the expected format.", + ) + prompt_values: dict[str, str] = SchemaField( + advanced=False, + default_factory=dict, + description="Values used to fill in the prompt. The values can be used in the prompt by putting them in a double curly braces, e.g. {{variable_name}}.", + ) + max_tokens: int | None = SchemaField( + advanced=True, + default=None, + description="The maximum number of tokens to generate in the chat completion.", + ) + ollama_host: str = SchemaField( + advanced=True, + default="localhost:11434", + description="Ollama host for local models", + ) + + @classmethod + def get_missing_links(cls, data: BlockInput, links: list["Link"]) -> set[str]: + # conversation_history & last_tool_output validation is handled differently + missing_links = super().get_missing_links( + data, + [ + link + for link in links + if link.sink_name + not in ["conversation_history", "last_tool_output"] + ], + ) + + # Avoid executing the block if the last_tool_output is connected to a static + # link, like StoreValueBlock or AgentInputBlock. + if any(link.sink_name == "conversation_history" for link in links) and any( + link.sink_name == "last_tool_output" and link.is_static + for link in links + ): + raise ValueError( + "Last Tool Output can't be connected to a static (dashed line) " + "link like the output of `StoreValue` or `AgentInput` block" + ) + + # Check that both conversation_history and last_tool_output are connected together + if any(link.sink_name == "conversation_history" for link in links) != any( + link.sink_name == "last_tool_output" for link in links + ): + raise ValueError( + "Last Tool Output is needed when Conversation History is used, " + "and vice versa. Please connect both inputs together." + ) + + return missing_links + + @classmethod + def get_missing_input(cls, data: BlockInput) -> set[str]: + if missing_input := super().get_missing_input(data): + return missing_input + + conversation_history = data.get("conversation_history", []) + pending_tool_calls = get_pending_tool_calls(conversation_history) + last_tool_output = data.get("last_tool_output") + + # Tool call is pending, wait for the tool output to be provided. + if last_tool_output is None and pending_tool_calls: + return {"last_tool_output"} + + # No tool call is pending, wait for the conversation history to be updated. + if last_tool_output is not None and not pending_tool_calls: + return {"conversation_history"} + + return set() + + class Output(BlockSchema): + error: str = SchemaField(description="Error message if the API call failed.") + tools: Any = SchemaField(description="The tools that are available to use.") + finished: str = SchemaField( + description="The finished message to display to the user." + ) + conversations: list[Any] = SchemaField( + description="The conversation history to provide context for the prompt." + ) + + def __init__(self): + super().__init__( + id="3b191d9f-356f-482d-8238-ba04b6d18381", + description="Uses AI to intelligently decide what tool to use.", + categories={BlockCategory.AI}, + block_type=BlockType.AI, + input_schema=SmartDecisionMakerBlock.Input, + output_schema=SmartDecisionMakerBlock.Output, + test_input={ + "prompt": "Hello, World!", + "credentials": llm.TEST_CREDENTIALS_INPUT, + }, + test_output=[], + test_credentials=llm.TEST_CREDENTIALS, + ) + + @staticmethod + def cleanup(s: str): + return re.sub(r"[^a-zA-Z0-9_-]", "_", s).lower() + + @staticmethod + async def _create_block_function_signature( + sink_node: "Node", links: list["Link"] + ) -> dict[str, Any]: + """ + Creates a function signature for a block node. + + Args: + sink_node: The node for which to create a function signature. + links: The list of links connected to the sink node. + + Returns: + A dictionary representing the function signature in the format expected by LLM tools. + + Raises: + ValueError: If the block specified by sink_node.block_id is not found. + """ + block = sink_node.block + + tool_function: dict[str, Any] = { + "name": SmartDecisionMakerBlock.cleanup(block.name), + "description": block.description, + } + sink_block_input_schema = block.input_schema + properties = {} + + for link in links: + sink_name = SmartDecisionMakerBlock.cleanup(link.sink_name) + + # Handle dynamic fields (e.g., values_#_*, items_$_*, etc.) + # These are fields that get merged by the executor into their base field + if ( + "_#_" in link.sink_name + or "_$_" in link.sink_name + or "_@_" in link.sink_name + ): + # For dynamic fields, provide a generic string schema + # The executor will handle merging these into the appropriate structure + properties[sink_name] = { + "type": "string", + "description": f"Dynamic value for {link.sink_name}", + } + else: + # For regular fields, use the block's schema + try: + properties[sink_name] = sink_block_input_schema.get_field_schema( + link.sink_name + ) + except (KeyError, AttributeError): + # If the field doesn't exist in the schema, provide a generic schema + properties[sink_name] = { + "type": "string", + "description": f"Value for {link.sink_name}", + } + + tool_function["parameters"] = { + **block.input_schema.jsonschema(), + "properties": properties, + } + + return {"type": "function", "function": tool_function} + + @staticmethod + async def _create_agent_function_signature( + sink_node: "Node", links: list["Link"] + ) -> dict[str, Any]: + """ + Creates a function signature for an agent node. + + Args: + sink_node: The agent node for which to create a function signature. + links: The list of links connected to the sink node. + + Returns: + A dictionary representing the function signature in the format expected by LLM tools. + + Raises: + ValueError: If the graph metadata for the specified graph_id and graph_version is not found. + """ + graph_id = sink_node.input_default.get("graph_id") + graph_version = sink_node.input_default.get("graph_version") + if not graph_id or not graph_version: + raise ValueError("Graph ID or Graph Version not found in sink node.") + + db_client = get_database_manager_async_client() + sink_graph_meta = await db_client.get_graph_metadata(graph_id, graph_version) + if not sink_graph_meta: + raise ValueError( + f"Sink graph metadata not found: {graph_id} {graph_version}" + ) + + tool_function: dict[str, Any] = { + "name": SmartDecisionMakerBlock.cleanup(sink_graph_meta.name), + "description": sink_graph_meta.description, + } + + properties = {} + + for link in links: + sink_block_input_schema = sink_node.input_default["input_schema"] + sink_block_properties = sink_block_input_schema.get("properties", {}).get( + link.sink_name, {} + ) + sink_name = SmartDecisionMakerBlock.cleanup(link.sink_name) + description = ( + sink_block_properties["description"] + if "description" in sink_block_properties + else f"The {link.sink_name} of the tool" + ) + properties[sink_name] = { + "type": "string", + "description": description, + "default": json.dumps(sink_block_properties.get("default", None)), + } + + tool_function["parameters"] = { + "type": "object", + "properties": properties, + "additionalProperties": False, + "strict": True, + } + + return {"type": "function", "function": tool_function} + + @staticmethod + async def _create_function_signature(node_id: str) -> list[dict[str, Any]]: + """ + Creates function signatures for tools linked to a specified node within a graph. + + This method filters the graph links to identify those that are tools and are + connected to the given node_id. It then constructs function signatures for each + tool based on the metadata and input schema of the linked nodes. + + Args: + node_id: The node_id for which to create function signatures. + + Returns: + list[dict[str, Any]]: A list of dictionaries, each representing a function signature + for a tool, including its name, description, and parameters. + + Raises: + ValueError: If no tool links are found for the specified node_id, or if a sink node + or its metadata cannot be found. + """ + db_client = get_database_manager_async_client() + tools = [ + (link, node) + for link, node in await db_client.get_connected_output_nodes(node_id) + if link.source_name.startswith("tools_^_") and link.source_id == node_id + ] + if not tools: + raise ValueError("There is no next node to execute.") + + return_tool_functions: list[dict[str, Any]] = [] + + grouped_tool_links: dict[str, tuple["Node", list["Link"]]] = {} + for link, node in tools: + if link.sink_id not in grouped_tool_links: + grouped_tool_links[link.sink_id] = (node, [link]) + else: + grouped_tool_links[link.sink_id][1].append(link) + + for sink_node, links in grouped_tool_links.values(): + if not sink_node: + raise ValueError(f"Sink node not found: {links[0].sink_id}") + + if sink_node.block_id == AgentExecutorBlock().id: + return_tool_functions.append( + await SmartDecisionMakerBlock._create_agent_function_signature( + sink_node, links + ) + ) + else: + return_tool_functions.append( + await SmartDecisionMakerBlock._create_block_function_signature( + sink_node, links + ) + ) + + return return_tool_functions + + async def run( + self, + input_data: Input, + *, + credentials: llm.APIKeyCredentials, + graph_id: str, + node_id: str, + graph_exec_id: str, + node_exec_id: str, + user_id: str, + **kwargs, + ) -> BlockOutput: + tool_functions = await self._create_function_signature(node_id) + yield "tool_functions", json.dumps(tool_functions) + + input_data.conversation_history = input_data.conversation_history or [] + prompt = [json.to_dict(p) for p in input_data.conversation_history if p] + + pending_tool_calls = get_pending_tool_calls(input_data.conversation_history) + if pending_tool_calls and input_data.last_tool_output is None: + raise ValueError(f"Tool call requires an output for {pending_tool_calls}") + + # Only assign the last tool output to the first pending tool call + tool_output = [] + if pending_tool_calls and input_data.last_tool_output is not None: + # Get the first pending tool call ID + first_call_id = next(iter(pending_tool_calls.keys())) + tool_output.append( + _create_tool_response(first_call_id, input_data.last_tool_output) + ) + + # Add tool output to prompt right away + prompt.extend(tool_output) + + # Check if there are still pending tool calls after handling the first one + remaining_pending_calls = get_pending_tool_calls(prompt) + + # If there are still pending tool calls, yield the conversation and return early + if remaining_pending_calls: + yield "conversations", prompt + return + + # Fallback on adding tool output in the conversation history as user prompt. + elif input_data.last_tool_output: + logger.error( + f"[SmartDecisionMakerBlock-node_exec_id={node_exec_id}] " + f"No pending tool calls found. This may indicate an issue with the " + f"conversation history, or the tool giving response more than once." + f"This should not happen! Please check the conversation history for any inconsistencies." + ) + tool_output.append( + { + "role": "user", + "content": f"Last tool output: {json.dumps(input_data.last_tool_output)}", + } + ) + prompt.extend(tool_output) + + values = input_data.prompt_values + if values: + input_data.prompt = llm.fmt.format_string(input_data.prompt, values) + input_data.sys_prompt = llm.fmt.format_string(input_data.sys_prompt, values) + + prefix = "[Main Objective Prompt]: " + + if input_data.sys_prompt and not any( + p["role"] == "system" and p["content"].startswith(prefix) for p in prompt + ): + prompt.append({"role": "system", "content": prefix + input_data.sys_prompt}) + + if input_data.prompt and not any( + p["role"] == "user" and p["content"].startswith(prefix) for p in prompt + ): + prompt.append({"role": "user", "content": prefix + input_data.prompt}) + + response = await llm.llm_call( + credentials=credentials, + llm_model=input_data.model, + prompt=prompt, + json_format=False, + max_tokens=input_data.max_tokens, + tools=tool_functions, + ollama_host=input_data.ollama_host, + parallel_tool_calls=input_data.multiple_tool_calls, + ) + + # Track LLM usage stats + self.merge_stats( + NodeExecutionStats( + input_token_count=response.prompt_tokens, + output_token_count=response.completion_tokens, + llm_call_count=1, + ) + ) + + if not response.tool_calls: + yield "finished", response.response + return + + for tool_call in response.tool_calls: + tool_name = tool_call.function.name + tool_args = json.loads(tool_call.function.arguments) + + # Find the tool definition to get the expected arguments + tool_def = next( + ( + tool + for tool in tool_functions + if tool["function"]["name"] == tool_name + ), + None, + ) + + if ( + tool_def + and "function" in tool_def + and "parameters" in tool_def["function"] + ): + expected_args = tool_def["function"]["parameters"].get("properties", {}) + else: + expected_args = tool_args.keys() + + # Yield provided arguments and None for missing ones + for arg_name in expected_args: + if arg_name in tool_args: + yield f"tools_^_{tool_name}_~_{arg_name}", tool_args[arg_name] + else: + yield f"tools_^_{tool_name}_~_{arg_name}", None + + # Add reasoning to conversation history if available + if response.reasoning: + prompt.append( + {"role": "assistant", "content": f"[Reasoning]: {response.reasoning}"} + ) + + prompt.append(response.raw_response) + yield "conversations", prompt diff --git a/autogpt_platform/backend/backend/blocks/smartlead/_api.py b/autogpt_platform/backend/backend/blocks/smartlead/_api.py new file mode 100644 index 000000000000..cff1c63b7247 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/smartlead/_api.py @@ -0,0 +1,99 @@ +from backend.blocks.smartlead.models import ( + AddLeadsRequest, + AddLeadsToCampaignResponse, + CreateCampaignRequest, + CreateCampaignResponse, + SaveSequencesRequest, + SaveSequencesResponse, +) +from backend.util.request import Requests + + +class SmartLeadClient: + """Client for the SmartLead API""" + + # This api is stupid and requires your api key in the url. DO NOT RAISE ERRORS FOR BAD REQUESTS. + # FILTER OUT THE API KEY FROM THE ERROR MESSAGE. + + API_URL = "https://server.smartlead.ai/api/v1" + + def __init__(self, api_key: str): + self.api_key = api_key + self.requests = Requests() + + def _add_auth_to_url(self, url: str) -> str: + return f"{url}?api_key={self.api_key}" + + def _handle_error(self, e: Exception) -> str: + return e.__str__().replace(self.api_key, "API KEY") + + async def create_campaign( + self, request: CreateCampaignRequest + ) -> CreateCampaignResponse: + try: + response = await self.requests.post( + self._add_auth_to_url(f"{self.API_URL}/campaigns/create"), + json=request.model_dump(), + ) + response_data = response.json() + return CreateCampaignResponse(**response_data) + except ValueError as e: + raise ValueError(f"Invalid response format: {str(e)}") + except Exception as e: + raise ValueError(f"Failed to create campaign: {self._handle_error(e)}") + + async def add_leads_to_campaign( + self, request: AddLeadsRequest + ) -> AddLeadsToCampaignResponse: + try: + response = await self.requests.post( + self._add_auth_to_url( + f"{self.API_URL}/campaigns/{request.campaign_id}/leads" + ), + json=request.model_dump(exclude={"campaign_id"}), + ) + response_data = response.json() + response_parsed = AddLeadsToCampaignResponse(**response_data) + if not response_parsed.ok: + raise ValueError( + f"Failed to add leads to campaign: {response_parsed.error}" + ) + return response_parsed + except ValueError as e: + raise ValueError(f"Invalid response format: {str(e)}") + except Exception as e: + raise ValueError( + f"Failed to add leads to campaign: {self._handle_error(e)}" + ) + + async def save_campaign_sequences( + self, campaign_id: int, request: SaveSequencesRequest + ) -> SaveSequencesResponse: + """ + Save sequences within a campaign. + + Args: + campaign_id: ID of the campaign to save sequences for + request: SaveSequencesRequest containing the sequences configuration + + Returns: + SaveSequencesResponse with the result of the operation + + Note: + For variant_distribution_type: + - MANUAL_EQUAL: Equally distributes variants across leads + - AI_EQUAL: Requires winning_metric_property and lead_distribution_percentage + - MANUAL_PERCENTAGE: Requires variant_distribution_percentage in seq_variants + """ + try: + response = await self.requests.post( + self._add_auth_to_url( + f"{self.API_URL}/campaigns/{campaign_id}/sequences" + ), + json=request.model_dump(exclude_none=True), + ) + return SaveSequencesResponse(**(response.json())) + except Exception as e: + raise ValueError( + f"Failed to save campaign sequences: {e.__str__().replace(self.api_key, 'API KEY')}" + ) diff --git a/autogpt_platform/backend/backend/blocks/smartlead/_auth.py b/autogpt_platform/backend/backend/blocks/smartlead/_auth.py new file mode 100644 index 000000000000..219524126c6e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/smartlead/_auth.py @@ -0,0 +1,35 @@ +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import APIKeyCredentials, CredentialsField, CredentialsMetaInput +from backend.integrations.providers import ProviderName + +SmartLeadCredentials = APIKeyCredentials +SmartLeadCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.SMARTLEAD], + Literal["api_key"], +] + +TEST_CREDENTIALS = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="smartlead", + api_key=SecretStr("mock-smartlead-api-key"), + title="Mock SmartLead API key", + expires_at=None, +) +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} + + +def SmartLeadCredentialsField() -> SmartLeadCredentialsInput: + """ + Creates a SmartLead credentials input on a block. + """ + return CredentialsField( + description="The SmartLead integration can be used with an API Key.", + ) diff --git a/autogpt_platform/backend/backend/blocks/smartlead/campaign.py b/autogpt_platform/backend/backend/blocks/smartlead/campaign.py new file mode 100644 index 000000000000..112004d7ad9a --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/smartlead/campaign.py @@ -0,0 +1,326 @@ +from backend.blocks.smartlead._api import SmartLeadClient +from backend.blocks.smartlead._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + SmartLeadCredentials, + SmartLeadCredentialsInput, +) +from backend.blocks.smartlead.models import ( + AddLeadsRequest, + AddLeadsToCampaignResponse, + CreateCampaignRequest, + CreateCampaignResponse, + LeadInput, + LeadUploadSettings, + SaveSequencesRequest, + SaveSequencesResponse, + Sequence, +) +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import CredentialsField, SchemaField + + +class CreateCampaignBlock(Block): + """Create a campaign in SmartLead""" + + class Input(BlockSchema): + name: str = SchemaField( + description="The name of the campaign", + ) + credentials: SmartLeadCredentialsInput = CredentialsField( + description="SmartLead credentials", + ) + + class Output(BlockSchema): + id: int = SchemaField( + description="The ID of the created campaign", + ) + name: str = SchemaField( + description="The name of the created campaign", + ) + created_at: str = SchemaField( + description="The date and time the campaign was created", + ) + error: str = SchemaField( + description="Error message if the search failed", + default="", + ) + + def __init__(self): + super().__init__( + id="8865699f-9188-43c4-89b0-79c84cfaa03e", + description="Create a campaign in SmartLead", + categories={BlockCategory.CRM}, + input_schema=CreateCampaignBlock.Input, + output_schema=CreateCampaignBlock.Output, + test_credentials=TEST_CREDENTIALS, + test_input={"name": "Test Campaign", "credentials": TEST_CREDENTIALS_INPUT}, + test_output=[ + ( + "id", + 1, + ), + ( + "name", + "Test Campaign", + ), + ( + "created_at", + "2024-01-01T00:00:00Z", + ), + ], + test_mock={ + "create_campaign": lambda name, credentials: CreateCampaignResponse( + ok=True, + id=1, + name=name, + created_at="2024-01-01T00:00:00Z", + ) + }, + ) + + @staticmethod + async def create_campaign( + name: str, credentials: SmartLeadCredentials + ) -> CreateCampaignResponse: + client = SmartLeadClient(credentials.api_key.get_secret_value()) + return await client.create_campaign(CreateCampaignRequest(name=name)) + + async def run( + self, + input_data: Input, + *, + credentials: SmartLeadCredentials, + **kwargs, + ) -> BlockOutput: + response = await self.create_campaign(input_data.name, credentials) + + yield "id", response.id + yield "name", response.name + yield "created_at", response.created_at + if not response.ok: + yield "error", "Failed to create campaign" + + +class AddLeadToCampaignBlock(Block): + """Add a lead to a campaign in SmartLead""" + + class Input(BlockSchema): + campaign_id: int = SchemaField( + description="The ID of the campaign to add the lead to", + ) + lead_list: list[LeadInput] = SchemaField( + description="An array of JSON objects, each representing a lead's details. Can hold max 100 leads.", + max_length=100, + default_factory=list, + advanced=False, + ) + settings: LeadUploadSettings = SchemaField( + description="Settings for lead upload", + default=LeadUploadSettings(), + ) + credentials: SmartLeadCredentialsInput = CredentialsField( + description="SmartLead credentials", + ) + + class Output(BlockSchema): + campaign_id: int = SchemaField( + description="The ID of the campaign the lead was added to (passed through)", + ) + upload_count: int = SchemaField( + description="The number of leads added to the campaign", + ) + already_added_to_campaign: int = SchemaField( + description="The number of leads that were already added to the campaign", + ) + duplicate_count: int = SchemaField( + description="The number of emails that were duplicates", + ) + invalid_email_count: int = SchemaField( + description="The number of emails that were invalidly formatted", + ) + is_lead_limit_exhausted: bool = SchemaField( + description="Whether the lead limit was exhausted", + ) + lead_import_stopped_count: int = SchemaField( + description="The number of leads that were not added to the campaign because the lead import was stopped", + ) + error: str = SchemaField( + description="Error message if the lead was not added to the campaign", + default="", + ) + + def __init__(self): + super().__init__( + id="fb8106a4-1a8f-42f9-a502-f6d07e6fe0ec", + description="Add a lead to a campaign in SmartLead", + categories={BlockCategory.CRM}, + input_schema=AddLeadToCampaignBlock.Input, + output_schema=AddLeadToCampaignBlock.Output, + test_credentials=TEST_CREDENTIALS, + test_input={ + "campaign_id": 1, + "lead_list": [], + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ( + "campaign_id", + 1, + ), + ( + "upload_count", + 1, + ), + ], + test_mock={ + "add_leads_to_campaign": lambda campaign_id, lead_list, credentials: AddLeadsToCampaignResponse( + ok=True, + upload_count=1, + already_added_to_campaign=0, + duplicate_count=0, + invalid_email_count=0, + is_lead_limit_exhausted=False, + lead_import_stopped_count=0, + error="", + total_leads=1, + block_count=0, + invalid_emails=[], + unsubscribed_leads=[], + bounce_count=0, + ) + }, + ) + + @staticmethod + async def add_leads_to_campaign( + campaign_id: int, lead_list: list[LeadInput], credentials: SmartLeadCredentials + ) -> AddLeadsToCampaignResponse: + client = SmartLeadClient(credentials.api_key.get_secret_value()) + return await client.add_leads_to_campaign( + AddLeadsRequest( + campaign_id=campaign_id, + lead_list=lead_list, + settings=LeadUploadSettings( + ignore_global_block_list=False, + ignore_unsubscribe_list=False, + ignore_community_bounce_list=False, + ignore_duplicate_leads_in_other_campaign=False, + ), + ), + ) + + async def run( + self, + input_data: Input, + *, + credentials: SmartLeadCredentials, + **kwargs, + ) -> BlockOutput: + response = await self.add_leads_to_campaign( + input_data.campaign_id, input_data.lead_list, credentials + ) + + yield "campaign_id", input_data.campaign_id + yield "upload_count", response.upload_count + if response.already_added_to_campaign: + yield "already_added_to_campaign", response.already_added_to_campaign + if response.duplicate_count: + yield "duplicate_count", response.duplicate_count + if response.invalid_email_count: + yield "invalid_email_count", response.invalid_email_count + if response.is_lead_limit_exhausted: + yield "is_lead_limit_exhausted", response.is_lead_limit_exhausted + if response.lead_import_stopped_count: + yield "lead_import_stopped_count", response.lead_import_stopped_count + if response.error: + yield "error", response.error + if not response.ok: + yield "error", "Failed to add leads to campaign" + + +class SaveCampaignSequencesBlock(Block): + """Save sequences within a campaign""" + + class Input(BlockSchema): + campaign_id: int = SchemaField( + description="The ID of the campaign to save sequences for", + ) + sequences: list[Sequence] = SchemaField( + description="The sequences to save", + default_factory=list, + advanced=False, + ) + credentials: SmartLeadCredentialsInput = CredentialsField( + description="SmartLead credentials", + ) + + class Output(BlockSchema): + data: dict | str | None = SchemaField( + description="Data from the API", + default=None, + ) + message: str = SchemaField( + description="Message from the API", + default="", + ) + error: str = SchemaField( + description="Error message if the sequences were not saved", + default="", + ) + + def __init__(self): + super().__init__( + id="e7d9f41c-dc10-4f39-98ba-a432abd128c0", + description="Save sequences within a campaign", + categories={BlockCategory.CRM}, + input_schema=SaveCampaignSequencesBlock.Input, + output_schema=SaveCampaignSequencesBlock.Output, + test_credentials=TEST_CREDENTIALS, + test_input={ + "campaign_id": 1, + "sequences": [], + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_output=[ + ( + "message", + "Sequences saved successfully", + ), + ], + test_mock={ + "save_campaign_sequences": lambda campaign_id, sequences, credentials: SaveSequencesResponse( + ok=True, + message="Sequences saved successfully", + ) + }, + ) + + @staticmethod + async def save_campaign_sequences( + campaign_id: int, sequences: list[Sequence], credentials: SmartLeadCredentials + ) -> SaveSequencesResponse: + client = SmartLeadClient(credentials.api_key.get_secret_value()) + return await client.save_campaign_sequences( + campaign_id=campaign_id, request=SaveSequencesRequest(sequences=sequences) + ) + + async def run( + self, + input_data: Input, + *, + credentials: SmartLeadCredentials, + **kwargs, + ) -> BlockOutput: + response = await self.save_campaign_sequences( + input_data.campaign_id, input_data.sequences, credentials + ) + + if response.data: + yield "data", response.data + if response.message: + yield "message", response.message + if response.error: + yield "error", response.error + if not response.ok: + yield "error", "Failed to save sequences" diff --git a/autogpt_platform/backend/backend/blocks/smartlead/models.py b/autogpt_platform/backend/backend/blocks/smartlead/models.py new file mode 100644 index 000000000000..236d04c95e4f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/smartlead/models.py @@ -0,0 +1,147 @@ +from enum import Enum + +from pydantic import BaseModel + +from backend.data.model import SchemaField + + +class CreateCampaignResponse(BaseModel): + ok: bool + id: int + name: str + created_at: str + + +class CreateCampaignRequest(BaseModel): + name: str + client_id: str | None = None + + +class AddLeadsToCampaignResponse(BaseModel): + ok: bool + upload_count: int + total_leads: int + block_count: int + duplicate_count: int + invalid_email_count: int + invalid_emails: list[str] + already_added_to_campaign: int + unsubscribed_leads: list[str] + is_lead_limit_exhausted: bool + lead_import_stopped_count: int + bounce_count: int + error: str | None = None + + +class LeadCustomFields(BaseModel): + """Custom fields for a lead (max 20 fields)""" + + fields: dict[str, str] = SchemaField( + description="Custom fields for a lead (max 20 fields)", + max_length=20, + default_factory=dict, + ) + + +class LeadInput(BaseModel): + """Single lead input data""" + + first_name: str + last_name: str + email: str + phone_number: str | None = None # Changed from int to str for phone numbers + company_name: str | None = None + website: str | None = None + location: str | None = None + custom_fields: LeadCustomFields | None = None + linkedin_profile: str | None = None + company_url: str | None = None + + +class LeadUploadSettings(BaseModel): + """Settings for lead upload""" + + ignore_global_block_list: bool = SchemaField( + description="Ignore the global block list", + default=False, + ) + ignore_unsubscribe_list: bool = SchemaField( + description="Ignore the unsubscribe list", + default=False, + ) + ignore_community_bounce_list: bool = SchemaField( + description="Ignore the community bounce list", + default=False, + ) + ignore_duplicate_leads_in_other_campaign: bool = SchemaField( + description="Ignore duplicate leads in other campaigns", + default=False, + ) + + +class AddLeadsRequest(BaseModel): + """Request body for adding leads to a campaign""" + + lead_list: list[LeadInput] = SchemaField( + description="List of leads to add to the campaign", + max_length=100, + default_factory=list, + ) + settings: LeadUploadSettings + campaign_id: int + + +class VariantDistributionType(str, Enum): + MANUAL_EQUAL = "MANUAL_EQUAL" + MANUAL_PERCENTAGE = "MANUAL_PERCENTAGE" + AI_EQUAL = "AI_EQUAL" + + +class WinningMetricProperty(str, Enum): + OPEN_RATE = "OPEN_RATE" + CLICK_RATE = "CLICK_RATE" + REPLY_RATE = "REPLY_RATE" + POSITIVE_REPLY_RATE = "POSITIVE_REPLY_RATE" + + +class SequenceDelayDetails(BaseModel): + delay_in_days: int + + +class SequenceVariant(BaseModel): + subject: str + email_body: str + variant_label: str + id: int | None = None # Optional for creation, required for updates + variant_distribution_percentage: int | None = None + + +class Sequence(BaseModel): + seq_number: int = SchemaField( + description="The sequence number", + default=1, + ) + seq_delay_details: SequenceDelayDetails + id: int | None = None + variant_distribution_type: VariantDistributionType | None = None + lead_distribution_percentage: int | None = SchemaField( + None, ge=20, le=100 + ) # >= 20% for fair calculation + winning_metric_property: WinningMetricProperty | None = None + seq_variants: list[SequenceVariant] | None = None + subject: str = "" # blank makes the follow up in the same thread + email_body: str | None = None + + +class SaveSequencesRequest(BaseModel): + sequences: list[Sequence] + + +class SaveSequencesResponse(BaseModel): + ok: bool + message: str = SchemaField( + description="Message from the API", + default="", + ) + data: dict | str | None = None + error: str | None = None diff --git a/autogpt_platform/backend/backend/blocks/spreadsheet.py b/autogpt_platform/backend/backend/blocks/spreadsheet.py new file mode 100644 index 000000000000..1de849e1b05f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/spreadsheet.py @@ -0,0 +1,180 @@ +from pathlib import Path + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import ContributorDetails, SchemaField +from backend.util.file import get_exec_file_path, store_media_file +from backend.util.type import MediaFileType + + +class ReadSpreadsheetBlock(Block): + class Input(BlockSchema): + contents: str | None = SchemaField( + description="The contents of the CSV/spreadsheet data to read", + placeholder="a, b, c\n1,2,3\n4,5,6", + default=None, + advanced=False, + ) + file_input: MediaFileType | None = SchemaField( + description="CSV or Excel file to read from (URL, data URI, or local path). Excel files are automatically converted to CSV", + default=None, + advanced=False, + ) + delimiter: str = SchemaField( + description="The delimiter used in the CSV/spreadsheet data", + default=",", + ) + quotechar: str = SchemaField( + description="The character used to quote fields", + default='"', + ) + escapechar: str = SchemaField( + description="The character used to escape the delimiter", + default="\\", + ) + has_header: bool = SchemaField( + description="Whether the CSV file has a header row", + default=True, + ) + skip_rows: int = SchemaField( + description="The number of rows to skip from the start of the file", + default=0, + ) + strip: bool = SchemaField( + description="Whether to strip whitespace from the values", + default=True, + ) + skip_columns: list[str] = SchemaField( + description="The columns to skip from the start of the row", + default_factory=list, + ) + produce_singular_result: bool = SchemaField( + description="If True, yield individual 'row' outputs only (can be slow). If False, yield both 'rows' (all data)", + default=False, + ) + + class Output(BlockSchema): + row: dict[str, str] = SchemaField( + description="The data produced from each row in the spreadsheet" + ) + rows: list[dict[str, str]] = SchemaField( + description="All the data in the spreadsheet as a list of rows" + ) + + def __init__(self): + super().__init__( + id="acf7625e-d2cb-4941-bfeb-2819fc6fc015", + input_schema=ReadSpreadsheetBlock.Input, + output_schema=ReadSpreadsheetBlock.Output, + description="Reads CSV and Excel files and outputs the data as a list of dictionaries and individual rows. Excel files are automatically converted to CSV format.", + contributors=[ContributorDetails(name="Nicholas Tindle")], + categories={BlockCategory.TEXT, BlockCategory.DATA}, + test_input=[ + { + "contents": "a, b, c\n1,2,3\n4,5,6", + "produce_singular_result": False, + }, + { + "contents": "a, b, c\n1,2,3\n4,5,6", + "produce_singular_result": True, + }, + ], + test_output=[ + ( + "rows", + [ + {"a": "1", "b": "2", "c": "3"}, + {"a": "4", "b": "5", "c": "6"}, + ], + ), + ("row", {"a": "1", "b": "2", "c": "3"}), + ("row", {"a": "4", "b": "5", "c": "6"}), + ], + ) + + async def run( + self, input_data: Input, *, graph_exec_id: str, user_id: str, **_kwargs + ) -> BlockOutput: + import csv + from io import StringIO + + # Determine data source - prefer file_input if provided, otherwise use contents + if input_data.file_input: + stored_file_path = await store_media_file( + user_id=user_id, + graph_exec_id=graph_exec_id, + file=input_data.file_input, + return_content=False, + ) + + # Get full file path + file_path = get_exec_file_path(graph_exec_id, stored_file_path) + if not Path(file_path).exists(): + raise ValueError(f"File does not exist: {file_path}") + + # Check if file is an Excel file and convert to CSV + file_extension = Path(file_path).suffix.lower() + + if file_extension in [".xlsx", ".xls"]: + # Handle Excel files + try: + from io import StringIO + + import pandas as pd + + # Read Excel file + df = pd.read_excel(file_path) + + # Convert to CSV string + csv_buffer = StringIO() + df.to_csv(csv_buffer, index=False) + csv_content = csv_buffer.getvalue() + + except ImportError: + raise ValueError( + "pandas library is required to read Excel files. Please install it." + ) + except Exception as e: + raise ValueError(f"Unable to read Excel file: {e}") + else: + # Handle CSV/text files + csv_content = Path(file_path).read_text(encoding="utf-8") + elif input_data.contents: + # Use direct string content + csv_content = input_data.contents + else: + raise ValueError("Either 'contents' or 'file_input' must be provided") + + csv_file = StringIO(csv_content) + reader = csv.reader( + csv_file, + delimiter=input_data.delimiter, + quotechar=input_data.quotechar, + escapechar=input_data.escapechar, + ) + + header = None + if input_data.has_header: + header = next(reader) + if input_data.strip: + header = [h.strip() for h in header] + + for _ in range(input_data.skip_rows): + next(reader) + + def process_row(row): + data = {} + for i, value in enumerate(row): + if i not in input_data.skip_columns: + if input_data.has_header and header: + data[header[i]] = value.strip() if input_data.strip else value + else: + data[str(i)] = value.strip() if input_data.strip else value + return data + + rows = [process_row(row) for row in reader] + + if input_data.produce_singular_result: + for processed_row in rows: + yield "row", processed_row + else: + yield "rows", rows diff --git a/autogpt_platform/backend/backend/blocks/stagehand/__init__.py b/autogpt_platform/backend/backend/blocks/stagehand/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/blocks/stagehand/_config.py b/autogpt_platform/backend/backend/blocks/stagehand/_config.py new file mode 100644 index 000000000000..43ec6cd5ac05 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/stagehand/_config.py @@ -0,0 +1,8 @@ +from backend.sdk import BlockCostType, ProviderBuilder + +stagehand = ( + ProviderBuilder("stagehand") + .with_api_key("STAGEHAND_API_KEY", "Stagehand API Key") + .with_base_cost(1, BlockCostType.RUN) + .build() +) diff --git a/autogpt_platform/backend/backend/blocks/stagehand/blocks.py b/autogpt_platform/backend/backend/blocks/stagehand/blocks.py new file mode 100644 index 000000000000..50ec368dd20b --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/stagehand/blocks.py @@ -0,0 +1,393 @@ +import logging +import signal +import threading +from contextlib import contextmanager +from enum import Enum + +# Monkey patch Stagehands to prevent signal handling in worker threads +import stagehand.main +from stagehand import Stagehand + +from backend.blocks.llm import ( + MODEL_METADATA, + AICredentials, + AICredentialsField, + LlmModel, + ModelMetadata, +) +from backend.blocks.stagehand._config import stagehand as stagehand_provider +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + SchemaField, +) + +# Store the original method +original_register_signal_handlers = stagehand.main.Stagehand._register_signal_handlers + + +def safe_register_signal_handlers(self): + """Only register signal handlers in the main thread""" + if threading.current_thread() is threading.main_thread(): + original_register_signal_handlers(self) + else: + # Skip signal handling in worker threads + pass + + +# Replace the method +stagehand.main.Stagehand._register_signal_handlers = safe_register_signal_handlers + + +@contextmanager +def disable_signal_handling(): + """Context manager to temporarily disable signal handling""" + if threading.current_thread() is not threading.main_thread(): + # In worker threads, temporarily replace signal.signal with a no-op + original_signal = signal.signal + + def noop_signal(*args, **kwargs): + pass + + signal.signal = noop_signal + try: + yield + finally: + signal.signal = original_signal + else: + # In main thread, don't modify anything + yield + + +logger = logging.getLogger(__name__) + + +class StagehandRecommendedLlmModel(str, Enum): + """ + This is subset of LLModel from autogpt_platform/backend/backend/blocks/llm.py + + It contains only the models recommended by Stagehand + """ + + # OpenAI + GPT41 = "gpt-4.1-2025-04-14" + GPT41_MINI = "gpt-4.1-mini-2025-04-14" + + # Anthropic + CLAUDE_3_7_SONNET = "claude-3-7-sonnet-20250219" + + @property + def provider_name(self) -> str: + """ + Returns the provider name for the model in the required format for Stagehand: + provider/model_name + """ + model_metadata = MODEL_METADATA[LlmModel(self.value)] + model_name = self.value + + if len(model_name.split("/")) == 1 and not self.value.startswith( + model_metadata.provider + ): + assert ( + model_metadata.provider != "open_router" + ), "Logic failed and open_router provider attempted to be prepended to model name! in stagehand/_config.py" + model_name = f"{model_metadata.provider}/{model_name}" + + logger.error(f"Model name: {model_name}") + return model_name + + @property + def provider(self) -> str: + return MODEL_METADATA[LlmModel(self.value)].provider + + @property + def metadata(self) -> ModelMetadata: + return MODEL_METADATA[LlmModel(self.value)] + + @property + def context_window(self) -> int: + return MODEL_METADATA[LlmModel(self.value)].context_window + + @property + def max_output_tokens(self) -> int | None: + return MODEL_METADATA[LlmModel(self.value)].max_output_tokens + + +class StagehandObserveBlock(Block): + class Input(BlockSchema): + # Browserbase credentials (Stagehand provider) or raw API key + stagehand_credentials: CredentialsMetaInput = ( + stagehand_provider.credentials_field( + description="Stagehand/Browserbase API key" + ) + ) + browserbase_project_id: str = SchemaField( + description="Browserbase project ID (required if using Browserbase)", + ) + # Model selection and credentials (provider-discriminated like llm.py) + model: StagehandRecommendedLlmModel = SchemaField( + title="LLM Model", + description="LLM to use for Stagehand (provider is inferred)", + default=StagehandRecommendedLlmModel.CLAUDE_3_7_SONNET, + advanced=False, + ) + model_credentials: AICredentials = AICredentialsField() + url: str = SchemaField( + description="URL to navigate to.", + ) + instruction: str = SchemaField( + description="Natural language description of elements or actions to discover.", + ) + iframes: bool = SchemaField( + description="Whether to search within iframes. If True, Stagehand will search for actions within iframes.", + default=True, + ) + domSettleTimeoutMs: int = SchemaField( + description="Timeout in milliseconds for DOM settlement.Wait longer for dynamic content", + default=45000, + ) + + class Output(BlockSchema): + selector: str = SchemaField(description="XPath selector to locate element.") + description: str = SchemaField(description="Human-readable description") + method: str | None = SchemaField(description="Suggested action method") + arguments: list[str] | None = SchemaField( + description="Additional action parameters" + ) + + def __init__(self): + super().__init__( + id="d3863944-0eaf-45c4-a0c9-63e0fe1ee8b9", + description="Find suggested actions for your workflows", + categories={BlockCategory.AI, BlockCategory.DEVELOPER_TOOLS}, + input_schema=StagehandObserveBlock.Input, + output_schema=StagehandObserveBlock.Output, + ) + + async def run( + self, + input_data: Input, + *, + stagehand_credentials: APIKeyCredentials, + model_credentials: APIKeyCredentials, + **kwargs, + ) -> BlockOutput: + + logger.info(f"OBSERVE: Stagehand credentials: {stagehand_credentials}") + logger.info( + f"OBSERVE: Model credentials: {model_credentials} for provider {model_credentials.provider} secret: {model_credentials.api_key.get_secret_value()}" + ) + + with disable_signal_handling(): + stagehand = Stagehand( + api_key=stagehand_credentials.api_key.get_secret_value(), + project_id=input_data.browserbase_project_id, + model_name=input_data.model.provider_name, + model_api_key=model_credentials.api_key.get_secret_value(), + ) + + await stagehand.init() + + page = stagehand.page + + assert page is not None, "Stagehand page is not initialized" + + await page.goto(input_data.url) + + observe_results = await page.observe( + input_data.instruction, + iframes=input_data.iframes, + domSettleTimeoutMs=input_data.domSettleTimeoutMs, + ) + for result in observe_results: + yield "selector", result.selector + yield "description", result.description + yield "method", result.method + yield "arguments", result.arguments + + +class StagehandActBlock(Block): + class Input(BlockSchema): + # Browserbase credentials (Stagehand provider) or raw API key + stagehand_credentials: CredentialsMetaInput = ( + stagehand_provider.credentials_field( + description="Stagehand/Browserbase API key" + ) + ) + browserbase_project_id: str = SchemaField( + description="Browserbase project ID (required if using Browserbase)", + ) + # Model selection and credentials (provider-discriminated like llm.py) + model: StagehandRecommendedLlmModel = SchemaField( + title="LLM Model", + description="LLM to use for Stagehand (provider is inferred)", + default=StagehandRecommendedLlmModel.CLAUDE_3_7_SONNET, + advanced=False, + ) + model_credentials: AICredentials = AICredentialsField() + url: str = SchemaField( + description="URL to navigate to.", + ) + action: list[str] = SchemaField( + description="Action to perform. Suggested actions are: click, fill, type, press, scroll, select from dropdown. For multi-step actions, add an entry for each step.", + ) + variables: dict[str, str] = SchemaField( + description="Variables to use in the action. Variables contains data you want the action to use.", + default_factory=dict, + ) + iframes: bool = SchemaField( + description="Whether to search within iframes. If True, Stagehand will search for actions within iframes.", + default=True, + ) + domSettleTimeoutMs: int = SchemaField( + description="Timeout in milliseconds for DOM settlement.Wait longer for dynamic content", + default=45000, + ) + timeoutMs: int = SchemaField( + description="Timeout in milliseconds for DOM ready. Extended timeout for slow-loading forms", + default=60000, + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the action was completed successfully" + ) + message: str = SchemaField(description="Details about the action’s execution.") + action: str = SchemaField(description="Action performed") + + def __init__(self): + super().__init__( + id="86eba68b-9549-4c0b-a0db-47d85a56cc27", + description="Interact with a web page by performing actions on a web page. Use it to build self-healing and deterministic automations that adapt to website chang.", + categories={BlockCategory.AI, BlockCategory.DEVELOPER_TOOLS}, + input_schema=StagehandActBlock.Input, + output_schema=StagehandActBlock.Output, + ) + + async def run( + self, + input_data: Input, + *, + stagehand_credentials: APIKeyCredentials, + model_credentials: APIKeyCredentials, + **kwargs, + ) -> BlockOutput: + + logger.info(f"ACT: Stagehand credentials: {stagehand_credentials}") + logger.info( + f"ACT: Model credentials: {model_credentials} for provider {model_credentials.provider} secret: {model_credentials.api_key.get_secret_value()}" + ) + + with disable_signal_handling(): + stagehand = Stagehand( + api_key=stagehand_credentials.api_key.get_secret_value(), + project_id=input_data.browserbase_project_id, + model_name=input_data.model.provider_name, + model_api_key=model_credentials.api_key.get_secret_value(), + ) + + await stagehand.init() + + page = stagehand.page + + assert page is not None, "Stagehand page is not initialized" + + await page.goto(input_data.url) + for action in input_data.action: + action_results = await page.act( + action, + variables=input_data.variables, + iframes=input_data.iframes, + domSettleTimeoutMs=input_data.domSettleTimeoutMs, + timeoutMs=input_data.timeoutMs, + ) + yield "success", action_results.success + yield "message", action_results.message + yield "action", action_results.action + + +class StagehandExtractBlock(Block): + class Input(BlockSchema): + # Browserbase credentials (Stagehand provider) or raw API key + stagehand_credentials: CredentialsMetaInput = ( + stagehand_provider.credentials_field( + description="Stagehand/Browserbase API key" + ) + ) + browserbase_project_id: str = SchemaField( + description="Browserbase project ID (required if using Browserbase)", + ) + # Model selection and credentials (provider-discriminated like llm.py) + model: StagehandRecommendedLlmModel = SchemaField( + title="LLM Model", + description="LLM to use for Stagehand (provider is inferred)", + default=StagehandRecommendedLlmModel.CLAUDE_3_7_SONNET, + advanced=False, + ) + model_credentials: AICredentials = AICredentialsField() + url: str = SchemaField( + description="URL to navigate to.", + ) + instruction: str = SchemaField( + description="Natural language description of elements or actions to discover.", + ) + iframes: bool = SchemaField( + description="Whether to search within iframes. If True, Stagehand will search for actions within iframes.", + default=True, + ) + domSettleTimeoutMs: int = SchemaField( + description="Timeout in milliseconds for DOM settlement.Wait longer for dynamic content", + default=45000, + ) + + class Output(BlockSchema): + extraction: str = SchemaField(description="Extracted data from the page.") + + def __init__(self): + super().__init__( + id="fd3c0b18-2ba6-46ae-9339-fcb40537ad98", + description="Extract structured data from a webpage.", + categories={BlockCategory.AI, BlockCategory.DEVELOPER_TOOLS}, + input_schema=StagehandExtractBlock.Input, + output_schema=StagehandExtractBlock.Output, + ) + + async def run( + self, + input_data: Input, + *, + stagehand_credentials: APIKeyCredentials, + model_credentials: APIKeyCredentials, + **kwargs, + ) -> BlockOutput: + + logger.info(f"EXTRACT: Stagehand credentials: {stagehand_credentials}") + logger.info( + f"EXTRACT: Model credentials: {model_credentials} for provider {model_credentials.provider} secret: {model_credentials.api_key.get_secret_value()}" + ) + + with disable_signal_handling(): + stagehand = Stagehand( + api_key=stagehand_credentials.api_key.get_secret_value(), + project_id=input_data.browserbase_project_id, + model_name=input_data.model.provider_name, + model_api_key=model_credentials.api_key.get_secret_value(), + ) + + await stagehand.init() + + page = stagehand.page + + assert page is not None, "Stagehand page is not initialized" + + await page.goto(input_data.url) + extraction = await page.extract( + input_data.instruction, + iframes=input_data.iframes, + domSettleTimeoutMs=input_data.domSettleTimeoutMs, + ) + yield "extraction", str(extraction.model_dump()["extraction"]) diff --git a/autogpt_platform/backend/backend/blocks/system/__init__.py b/autogpt_platform/backend/backend/blocks/system/__init__.py new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/system/__init__.py @@ -0,0 +1 @@ + diff --git a/autogpt_platform/backend/backend/blocks/system/library_operations.py b/autogpt_platform/backend/backend/blocks/system/library_operations.py new file mode 100644 index 000000000000..2cf1aec0db26 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/system/library_operations.py @@ -0,0 +1,283 @@ +import logging +from typing import Any + +from pydantic import BaseModel + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField +from backend.util.clients import get_database_manager_async_client + +logger = logging.getLogger(__name__) + + +# Duplicate pydantic models for store data so we don't accidently change the data shape in the blocks unintentionally when editing the backend +class LibraryAgent(BaseModel): + """Model representing an agent in the user's library.""" + + library_agent_id: str = "" + agent_id: str = "" + agent_version: int = 0 + agent_name: str = "" + description: str = "" + creator: str = "" + is_archived: bool = False + categories: list[str] = [] + + +class AddToLibraryFromStoreBlock(Block): + """ + Block that adds an agent from the store to the user's library. + This enables users to easily import agents from the marketplace into their personal collection. + """ + + class Input(BlockSchema): + store_listing_version_id: str = SchemaField( + description="The ID of the store listing version to add to library" + ) + agent_name: str | None = SchemaField( + description="Optional custom name for the agent in your library", + default=None, + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the agent was successfully added to library" + ) + library_agent_id: str = SchemaField( + description="The ID of the library agent entry" + ) + agent_id: str = SchemaField(description="The ID of the agent graph") + agent_version: int = SchemaField( + description="The version number of the agent graph" + ) + agent_name: str = SchemaField(description="The name of the agent") + message: str = SchemaField(description="Success or error message") + + def __init__(self): + super().__init__( + id="2602a7b1-3f4d-4e5f-9c8b-1a2b3c4d5e6f", + description="Add an agent from the store to your personal library", + categories={BlockCategory.BASIC}, + input_schema=AddToLibraryFromStoreBlock.Input, + output_schema=AddToLibraryFromStoreBlock.Output, + test_input={ + "store_listing_version_id": "test-listing-id", + "agent_name": "My Custom Agent", + }, + test_output=[ + ("success", True), + ("library_agent_id", "test-library-id"), + ("agent_id", "test-agent-id"), + ("agent_version", 1), + ("agent_name", "Test Agent"), + ("message", "Agent successfully added to library"), + ], + test_mock={ + "_add_to_library": lambda *_, **__: LibraryAgent( + library_agent_id="test-library-id", + agent_id="test-agent-id", + agent_version=1, + agent_name="Test Agent", + ) + }, + ) + + async def run( + self, + input_data: Input, + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + library_agent = await self._add_to_library( + user_id=user_id, + store_listing_version_id=input_data.store_listing_version_id, + custom_name=input_data.agent_name, + ) + + yield "success", True + yield "library_agent_id", library_agent.library_agent_id + yield "agent_id", library_agent.agent_id + yield "agent_version", library_agent.agent_version + yield "agent_name", library_agent.agent_name + yield "message", "Agent successfully added to library" + + async def _add_to_library( + self, + user_id: str, + store_listing_version_id: str, + custom_name: str | None = None, + ) -> LibraryAgent: + """ + Add a store agent to the user's library using the existing library database function. + """ + library_agent = ( + await get_database_manager_async_client().add_store_agent_to_library( + store_listing_version_id=store_listing_version_id, user_id=user_id + ) + ) + + # If custom name is provided, we could update the library agent name here + # For now, we'll just return the agent info + agent_name = custom_name if custom_name else library_agent.name + + return LibraryAgent( + library_agent_id=library_agent.id, + agent_id=library_agent.graph_id, + agent_version=library_agent.graph_version, + agent_name=agent_name, + ) + + +class ListLibraryAgentsBlock(Block): + """ + Block that lists all agents in the user's library. + """ + + class Input(BlockSchema): + search_query: str | None = SchemaField( + description="Optional search query to filter agents", default=None + ) + limit: int = SchemaField( + description="Maximum number of agents to return", default=50, ge=1, le=100 + ) + page: int = SchemaField( + description="Page number for pagination", default=1, ge=1 + ) + + class Output(BlockSchema): + agents: list[LibraryAgent] = SchemaField( + description="List of agents in the library", + default_factory=list, + ) + agent: LibraryAgent = SchemaField( + description="Individual library agent (yielded for each agent)" + ) + total_count: int = SchemaField( + description="Total number of agents in library", default=0 + ) + page: int = SchemaField(description="Current page number", default=1) + total_pages: int = SchemaField(description="Total number of pages", default=1) + + def __init__(self): + super().__init__( + id="082602d3-a74d-4600-9e9c-15b3af7eae98", + description="List all agents in your personal library", + categories={BlockCategory.BASIC, BlockCategory.DATA}, + input_schema=ListLibraryAgentsBlock.Input, + output_schema=ListLibraryAgentsBlock.Output, + test_input={ + "search_query": None, + "limit": 10, + "page": 1, + }, + test_output=[ + ( + "agents", + [ + LibraryAgent( + library_agent_id="test-lib-id", + agent_id="test-agent-id", + agent_version=1, + agent_name="Test Library Agent", + description="A test agent in library", + creator="Test User", + ), + ], + ), + ("total_count", 1), + ("page", 1), + ("total_pages", 1), + ( + "agent", + LibraryAgent( + library_agent_id="test-lib-id", + agent_id="test-agent-id", + agent_version=1, + agent_name="Test Library Agent", + description="A test agent in library", + creator="Test User", + ), + ), + ], + test_mock={ + "_list_library_agents": lambda *_, **__: { + "agents": [ + LibraryAgent( + library_agent_id="test-lib-id", + agent_id="test-agent-id", + agent_version=1, + agent_name="Test Library Agent", + description="A test agent in library", + creator="Test User", + ) + ], + "total": 1, + "page": 1, + "total_pages": 1, + } + }, + ) + + async def run( + self, + input_data: Input, + *, + user_id: str, + **kwargs, + ) -> BlockOutput: + result = await self._list_library_agents( + user_id=user_id, + search_query=input_data.search_query, + limit=input_data.limit, + page=input_data.page, + ) + + agents = result["agents"] + + yield "agents", agents + yield "total_count", result["total"] + yield "page", result["page"] + yield "total_pages", result["total_pages"] + + # Yield each agent individually for better graph connectivity + for agent in agents: + yield "agent", agent + + async def _list_library_agents( + self, + user_id: str, + search_query: str | None = None, + limit: int = 50, + page: int = 1, + ) -> dict[str, Any]: + """ + List agents in the user's library using the database client. + """ + result = await get_database_manager_async_client().list_library_agents( + user_id=user_id, + search_term=search_query, + page=page, + page_size=limit, + ) + + agents = [ + LibraryAgent( + library_agent_id=agent.id, + agent_id=agent.graph_id, + agent_version=agent.graph_version, + agent_name=agent.name, + description=getattr(agent, "description", ""), + creator=getattr(agent, "creator", ""), + is_archived=getattr(agent, "is_archived", False), + categories=getattr(agent, "categories", []), + ) + for agent in result.agents + ] + + return { + "agents": agents, + "total": result.pagination.total_items, + "page": result.pagination.current_page, + "total_pages": result.pagination.total_pages, + } diff --git a/autogpt_platform/backend/backend/blocks/system/store_operations.py b/autogpt_platform/backend/backend/blocks/system/store_operations.py new file mode 100644 index 000000000000..6f5763bc93a0 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/system/store_operations.py @@ -0,0 +1,311 @@ +import logging +from typing import Literal + +from pydantic import BaseModel + +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField +from backend.util.clients import get_database_manager_async_client + +logger = logging.getLogger(__name__) + + +# Duplicate pydantic models for store data so we don't accidently change the data shape in the blocks unintentionally when editing the backend +class StoreAgent(BaseModel): + """Model representing a store agent.""" + + slug: str = "" + name: str = "" + description: str = "" + creator: str = "" + rating: float = 0.0 + runs: int = 0 + categories: list[str] = [] + + +class StoreAgentDict(BaseModel): + """Dictionary representation of a store agent.""" + + slug: str + name: str + description: str + creator: str + rating: float + runs: int + + +class SearchAgentsResponse(BaseModel): + """Response from searching store agents.""" + + agents: list[StoreAgentDict] + total_count: int + + +class StoreAgentDetails(BaseModel): + """Detailed information about a store agent.""" + + found: bool + store_listing_version_id: str = "" + agent_name: str = "" + description: str = "" + creator: str = "" + categories: list[str] = [] + runs: int = 0 + rating: float = 0.0 + + +class GetStoreAgentDetailsBlock(Block): + """ + Block that retrieves detailed information about an agent from the store. + """ + + class Input(BlockSchema): + creator: str = SchemaField(description="The username of the agent creator") + slug: str = SchemaField(description="The name of the agent") + + class Output(BlockSchema): + found: bool = SchemaField( + description="Whether the agent was found in the store" + ) + store_listing_version_id: str = SchemaField( + description="The store listing version ID" + ) + agent_name: str = SchemaField(description="Name of the agent") + description: str = SchemaField(description="Description of the agent") + creator: str = SchemaField(description="Creator of the agent") + categories: list[str] = SchemaField( + description="Categories the agent belongs to", default_factory=list + ) + runs: int = SchemaField( + description="Number of times the agent has been run", default=0 + ) + rating: float = SchemaField( + description="Average rating of the agent", default=0.0 + ) + + def __init__(self): + super().__init__( + id="b604f0ec-6e0d-40a7-bf55-9fd09997cced", + description="Get detailed information about an agent from the store", + categories={BlockCategory.BASIC, BlockCategory.DATA}, + input_schema=GetStoreAgentDetailsBlock.Input, + output_schema=GetStoreAgentDetailsBlock.Output, + test_input={"creator": "test-creator", "slug": "test-agent-slug"}, + test_output=[ + ("found", True), + ("store_listing_version_id", "test-listing-id"), + ("agent_name", "Test Agent"), + ("description", "A test agent"), + ("creator", "Test Creator"), + ("categories", ["productivity", "automation"]), + ("runs", 100), + ("rating", 4.5), + ], + test_mock={ + "_get_agent_details": lambda *_, **__: StoreAgentDetails( + found=True, + store_listing_version_id="test-listing-id", + agent_name="Test Agent", + description="A test agent", + creator="Test Creator", + categories=["productivity", "automation"], + runs=100, + rating=4.5, + ) + }, + static_output=True, + ) + + async def run( + self, + input_data: Input, + **kwargs, + ) -> BlockOutput: + details = await self._get_agent_details( + creator=input_data.creator, slug=input_data.slug + ) + yield "found", details.found + yield "store_listing_version_id", details.store_listing_version_id + yield "agent_name", details.agent_name + yield "description", details.description + yield "creator", details.creator + yield "categories", details.categories + yield "runs", details.runs + yield "rating", details.rating + + async def _get_agent_details(self, creator: str, slug: str) -> StoreAgentDetails: + """ + Retrieve detailed information about a store agent. + """ + # Get by specific version ID + agent_details = ( + await get_database_manager_async_client().get_store_agent_details( + username=creator, agent_name=slug + ) + ) + + return StoreAgentDetails( + found=True, + store_listing_version_id=agent_details.store_listing_version_id, + agent_name=agent_details.agent_name, + description=agent_details.description, + creator=agent_details.creator, + categories=( + agent_details.categories if hasattr(agent_details, "categories") else [] + ), + runs=agent_details.runs, + rating=agent_details.rating, + ) + + +class SearchStoreAgentsBlock(Block): + """ + Block that searches for agents in the store based on various criteria. + """ + + class Input(BlockSchema): + query: str | None = SchemaField( + description="Search query to find agents", default=None + ) + category: str | None = SchemaField( + description="Filter by category", default=None + ) + sort_by: Literal["rating", "runs", "name", "recent"] = SchemaField( + description="How to sort the results", default="rating" + ) + limit: int = SchemaField( + description="Maximum number of results to return", default=10, ge=1, le=100 + ) + + class Output(BlockSchema): + agents: list[StoreAgent] = SchemaField( + description="List of agents matching the search criteria", + default_factory=list, + ) + agent: StoreAgent = SchemaField(description="Basic information of the agent") + total_count: int = SchemaField( + description="Total number of agents found", default=0 + ) + + def __init__(self): + super().__init__( + id="39524701-026c-4328-87cc-1b88c8e2cb4c", + description="Search for agents in the store", + categories={BlockCategory.BASIC, BlockCategory.DATA}, + input_schema=SearchStoreAgentsBlock.Input, + output_schema=SearchStoreAgentsBlock.Output, + test_input={ + "query": "productivity", + "category": None, + "sort_by": "rating", + "limit": 10, + }, + test_output=[ + ( + "agents", + [ + { + "slug": "test-agent", + "name": "Test Agent", + "description": "A test agent", + "creator": "Test Creator", + "rating": 4.5, + "runs": 100, + } + ], + ), + ("total_count", 1), + ( + "agent", + { + "slug": "test-agent", + "name": "Test Agent", + "description": "A test agent", + "creator": "Test Creator", + "rating": 4.5, + "runs": 100, + }, + ), + ], + test_mock={ + "_search_agents": lambda *_, **__: SearchAgentsResponse( + agents=[ + StoreAgentDict( + slug="test-agent", + name="Test Agent", + description="A test agent", + creator="Test Creator", + rating=4.5, + runs=100, + ) + ], + total_count=1, + ) + }, + ) + + async def run( + self, + input_data: Input, + **kwargs, + ) -> BlockOutput: + result = await self._search_agents( + query=input_data.query, + category=input_data.category, + sort_by=input_data.sort_by, + limit=input_data.limit, + ) + + agents = result.agents + total_count = result.total_count + + # Convert to dict for output + agents_as_dicts = [agent.model_dump() for agent in agents] + + yield "agents", agents_as_dicts + yield "total_count", total_count + + for agent_dict in agents_as_dicts: + yield "agent", agent_dict + + async def _search_agents( + self, + query: str | None = None, + category: str | None = None, + sort_by: str = "rating", + limit: int = 10, + ) -> SearchAgentsResponse: + """ + Search for agents in the store using the existing store database function. + """ + # Map our sort_by to the store's sorted_by parameter + sorted_by_map = { + "rating": "most_popular", + "runs": "most_runs", + "name": "alphabetical", + "recent": "recently_updated", + } + + result = await get_database_manager_async_client().get_store_agents( + featured=False, + creators=None, + sorted_by=sorted_by_map.get(sort_by, "most_popular"), + search_query=query, + category=category, + page=1, + page_size=limit, + ) + + agents = [ + StoreAgentDict( + slug=agent.slug, + name=agent.agent_name, + description=agent.description, + creator=agent.creator, + rating=agent.rating, + runs=agent.runs, + ) + for agent in result.agents + ] + + return SearchAgentsResponse(agents=agents, total_count=len(agents)) diff --git a/autogpt_platform/backend/backend/blocks/talking_head.py b/autogpt_platform/backend/backend/blocks/talking_head.py index e1965dbc64bf..3861cb7752f7 100644 --- a/autogpt_platform/backend/backend/blocks/talking_head.py +++ b/autogpt_platform/backend/backend/blocks/talking_head.py @@ -1,4 +1,4 @@ -import time +import asyncio from typing import Literal from pydantic import SecretStr @@ -11,7 +11,7 @@ SchemaField, ) from backend.integrations.providers import ProviderName -from backend.util.request import requests +from backend.util.request import Requests TEST_CREDENTIALS = APIKeyCredentials( id="01234567-89ab-cdef-0123-456789abcdef", @@ -78,7 +78,7 @@ def __init__(self): super().__init__( id="98c6f503-8c47-4b1c-a96d-351fc7c87dab", description="This block integrates with D-ID to create video clips and retrieve their URLs.", - categories={BlockCategory.AI}, + categories={BlockCategory.AI, BlockCategory.MULTIMEDIA}, input_schema=CreateTalkingAvatarVideoBlock.Input, output_schema=CreateTalkingAvatarVideoBlock.Output, test_input={ @@ -113,26 +113,26 @@ def __init__(self): test_credentials=TEST_CREDENTIALS, ) - def create_clip(self, api_key: SecretStr, payload: dict) -> dict: + async def create_clip(self, api_key: SecretStr, payload: dict) -> dict: url = "https://api.d-id.com/clips" headers = { "accept": "application/json", "content-type": "application/json", "authorization": f"Basic {api_key.get_secret_value()}", } - response = requests.post(url, json=payload, headers=headers) + response = await Requests().post(url, json=payload, headers=headers) return response.json() - def get_clip_status(self, api_key: SecretStr, clip_id: str) -> dict: + async def get_clip_status(self, api_key: SecretStr, clip_id: str) -> dict: url = f"https://api.d-id.com/clips/{clip_id}" headers = { "accept": "application/json", "authorization": f"Basic {api_key.get_secret_value()}", } - response = requests.get(url, headers=headers) + response = await Requests().get(url, headers=headers) return response.json() - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: # Create the clip @@ -153,12 +153,12 @@ def run( "driver_id": input_data.driver_id, } - response = self.create_clip(credentials.api_key, payload) + response = await self.create_clip(credentials.api_key, payload) clip_id = response["id"] # Poll for clip status for _ in range(input_data.max_polling_attempts): - status_response = self.get_clip_status(credentials.api_key, clip_id) + status_response = await self.get_clip_status(credentials.api_key, clip_id) if status_response["status"] == "done": yield "video_url", status_response["result_url"] return @@ -167,6 +167,6 @@ def run( f"Clip creation failed: {status_response.get('error', 'Unknown error')}" ) - time.sleep(input_data.polling_interval) + await asyncio.sleep(input_data.polling_interval) raise TimeoutError("Clip creation timed out") diff --git a/autogpt_platform/backend/backend/blocks/test/test_block.py b/autogpt_platform/backend/backend/blocks/test/test_block.py new file mode 100644 index 000000000000..5876780639a0 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/test/test_block.py @@ -0,0 +1,125 @@ +from typing import Type + +import pytest + +from backend.data.block import Block, get_blocks +from backend.util.test import execute_block_test + + +@pytest.mark.parametrize("block", get_blocks().values(), ids=lambda b: b.name) +async def test_available_blocks(block: Type[Block]): + await execute_block_test(block()) + + +@pytest.mark.parametrize("block", get_blocks().values(), ids=lambda b: b.name) +async def test_block_ids_valid(block: Type[Block]): + # add the tests here to check they are uuid4 + import uuid + + # Skip list for blocks with known invalid UUIDs + skip_blocks = { + "GetWeatherInformationBlock", + "CodeExecutionBlock", + "CountdownTimerBlock", + "TwitterGetListTweetsBlock", + "TwitterRemoveListMemberBlock", + "TwitterAddListMemberBlock", + "TwitterGetListMembersBlock", + "TwitterGetListMembershipsBlock", + "TwitterUnfollowListBlock", + "TwitterFollowListBlock", + "TwitterUnpinListBlock", + "TwitterPinListBlock", + "TwitterGetPinnedListsBlock", + "TwitterDeleteListBlock", + "TwitterUpdateListBlock", + "TwitterCreateListBlock", + "TwitterGetListBlock", + "TwitterGetOwnedListsBlock", + "TwitterGetSpacesBlock", + "TwitterGetSpaceByIdBlock", + "TwitterGetSpaceBuyersBlock", + "TwitterGetSpaceTweetsBlock", + "TwitterSearchSpacesBlock", + "TwitterGetUserMentionsBlock", + "TwitterGetHomeTimelineBlock", + "TwitterGetUserTweetsBlock", + "TwitterGetTweetBlock", + "TwitterGetTweetsBlock", + "TwitterGetQuoteTweetsBlock", + "TwitterLikeTweetBlock", + "TwitterGetLikingUsersBlock", + "TwitterGetLikedTweetsBlock", + "TwitterUnlikeTweetBlock", + "TwitterBookmarkTweetBlock", + "TwitterGetBookmarkedTweetsBlock", + "TwitterRemoveBookmarkTweetBlock", + "TwitterRetweetBlock", + "TwitterRemoveRetweetBlock", + "TwitterGetRetweetersBlock", + "TwitterHideReplyBlock", + "TwitterUnhideReplyBlock", + "TwitterPostTweetBlock", + "TwitterDeleteTweetBlock", + "TwitterSearchRecentTweetsBlock", + "TwitterUnfollowUserBlock", + "TwitterFollowUserBlock", + "TwitterGetFollowersBlock", + "TwitterGetFollowingBlock", + "TwitterUnmuteUserBlock", + "TwitterGetMutedUsersBlock", + "TwitterMuteUserBlock", + "TwitterGetBlockedUsersBlock", + "TwitterGetUserBlock", + "TwitterGetUsersBlock", + "TodoistCreateLabelBlock", + "TodoistListLabelsBlock", + "TodoistGetLabelBlock", + "TodoistUpdateLabelBlock", + "TodoistDeleteLabelBlock", + "TodoistGetSharedLabelsBlock", + "TodoistRenameSharedLabelsBlock", + "TodoistRemoveSharedLabelsBlock", + "TodoistCreateTaskBlock", + "TodoistGetTasksBlock", + "TodoistGetTaskBlock", + "TodoistUpdateTaskBlock", + "TodoistCloseTaskBlock", + "TodoistReopenTaskBlock", + "TodoistDeleteTaskBlock", + "TodoistListSectionsBlock", + "TodoistGetSectionBlock", + "TodoistDeleteSectionBlock", + "TodoistCreateProjectBlock", + "TodoistGetProjectBlock", + "TodoistUpdateProjectBlock", + "TodoistDeleteProjectBlock", + "TodoistListCollaboratorsBlock", + "TodoistGetCommentsBlock", + "TodoistGetCommentBlock", + "TodoistUpdateCommentBlock", + "TodoistDeleteCommentBlock", + "GithubListStargazersBlock", + "Slant3DSlicerBlock", + } + + block_instance = block() + + # Skip blocks with known invalid UUIDs + if block_instance.__class__.__name__ in skip_blocks: + pytest.skip( + f"Skipping UUID check for {block_instance.__class__.__name__} - known invalid UUID" + ) + + # Check that the ID is not empty + assert block_instance.id, f"Block {block.name} has empty ID" + + # Check that the ID is a valid UUID4 + try: + parsed_uuid = uuid.UUID(block_instance.id) + # Verify it's specifically UUID version 4 + assert ( + parsed_uuid.version == 4 + ), f"Block {block.name} ID is UUID version {parsed_uuid.version}, expected version 4" + except ValueError: + pytest.fail(f"Block {block.name} has invalid UUID format: {block_instance.id}") diff --git a/autogpt_platform/backend/backend/blocks/test/test_http.py b/autogpt_platform/backend/backend/blocks/test/test_http.py new file mode 100644 index 000000000000..bdc30f3ecf3a --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/test/test_http.py @@ -0,0 +1,492 @@ +"""Comprehensive tests for HTTP block with HostScopedCredentials functionality.""" + +from typing import cast +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import SecretStr + +from backend.blocks.http import ( + HttpCredentials, + HttpMethod, + SendAuthenticatedWebRequestBlock, +) +from backend.data.model import HostScopedCredentials +from backend.util.request import Response + + +class TestHttpBlockWithHostScopedCredentials: + """Test suite for HTTP block integration with HostScopedCredentials.""" + + @pytest.fixture + def http_block(self): + """Create an HTTP block instance.""" + return SendAuthenticatedWebRequestBlock() + + @pytest.fixture + def mock_response(self): + """Mock a successful HTTP response.""" + response = MagicMock(spec=Response) + response.status = 200 + response.headers = {"content-type": "application/json"} + response.json.return_value = {"success": True, "data": "test"} + return response + + @pytest.fixture + def exact_match_credentials(self): + """Create host-scoped credentials for exact domain matching.""" + return HostScopedCredentials( + provider="http", + host="api.example.com", + headers={ + "Authorization": SecretStr("Bearer exact-match-token"), + "X-API-Key": SecretStr("api-key-123"), + }, + title="Exact Match API Credentials", + ) + + @pytest.fixture + def wildcard_credentials(self): + """Create host-scoped credentials with wildcard pattern.""" + return HostScopedCredentials( + provider="http", + host="*.github.com", + headers={ + "Authorization": SecretStr("token ghp_wildcard123"), + }, + title="GitHub Wildcard Credentials", + ) + + @pytest.fixture + def non_matching_credentials(self): + """Create credentials that don't match test URLs.""" + return HostScopedCredentials( + provider="http", + host="different.api.com", + headers={ + "Authorization": SecretStr("Bearer non-matching-token"), + }, + title="Non-matching Credentials", + ) + + @pytest.mark.asyncio + @patch("backend.blocks.http.Requests") + async def test_http_block_with_exact_host_match( + self, + mock_requests_class, + http_block, + exact_match_credentials, + mock_response, + ): + """Test HTTP block with exact host matching credentials.""" + # Setup mocks + mock_requests = AsyncMock() + mock_requests.request.return_value = mock_response + mock_requests_class.return_value = mock_requests + + # Prepare input data + input_data = SendAuthenticatedWebRequestBlock.Input( + url="https://api.example.com/data", + method=HttpMethod.GET, + headers={"User-Agent": "test-agent"}, + credentials=cast( + HttpCredentials, + { + "id": exact_match_credentials.id, + "provider": "http", + "type": "host_scoped", + "title": exact_match_credentials.title, + }, + ), + ) + + # Execute with credentials provided by execution manager + result = [] + async for output_name, output_data in http_block.run( + input_data, + credentials=exact_match_credentials, + graph_exec_id="test-exec-id", + user_id="test-user-id", + ): + result.append((output_name, output_data)) + + # Verify request headers include both credential and user headers + mock_requests.request.assert_called_once() + call_args = mock_requests.request.call_args + expected_headers = { + "Authorization": "Bearer exact-match-token", + "X-API-Key": "api-key-123", + "User-Agent": "test-agent", + } + assert call_args.kwargs["headers"] == expected_headers + + # Verify response handling + assert len(result) == 1 + assert result[0][0] == "response" + assert result[0][1] == {"success": True, "data": "test"} + + @pytest.mark.asyncio + @patch("backend.blocks.http.Requests") + async def test_http_block_with_wildcard_host_match( + self, + mock_requests_class, + http_block, + wildcard_credentials, + mock_response, + ): + """Test HTTP block with wildcard host pattern matching.""" + # Setup mocks + mock_requests = AsyncMock() + mock_requests.request.return_value = mock_response + mock_requests_class.return_value = mock_requests + + # Test with subdomain that should match *.github.com + input_data = SendAuthenticatedWebRequestBlock.Input( + url="https://api.github.com/user", + method=HttpMethod.GET, + headers={}, + credentials=cast( + HttpCredentials, + { + "id": wildcard_credentials.id, + "provider": "http", + "type": "host_scoped", + "title": wildcard_credentials.title, + }, + ), + ) + + # Execute with wildcard credentials + result = [] + async for output_name, output_data in http_block.run( + input_data, + credentials=wildcard_credentials, + graph_exec_id="test-exec-id", + user_id="test-user-id", + ): + result.append((output_name, output_data)) + + # Verify wildcard matching works + mock_requests.request.assert_called_once() + call_args = mock_requests.request.call_args + expected_headers = {"Authorization": "token ghp_wildcard123"} + assert call_args.kwargs["headers"] == expected_headers + + @pytest.mark.asyncio + @patch("backend.blocks.http.Requests") + async def test_http_block_with_non_matching_credentials( + self, + mock_requests_class, + http_block, + non_matching_credentials, + mock_response, + ): + """Test HTTP block when credentials don't match the target URL.""" + # Setup mocks + mock_requests = AsyncMock() + mock_requests.request.return_value = mock_response + mock_requests_class.return_value = mock_requests + + # Test with URL that doesn't match the credentials + input_data = SendAuthenticatedWebRequestBlock.Input( + url="https://api.example.com/data", + method=HttpMethod.GET, + headers={"User-Agent": "test-agent"}, + credentials=cast( + HttpCredentials, + { + "id": non_matching_credentials.id, + "provider": "http", + "type": "host_scoped", + "title": non_matching_credentials.title, + }, + ), + ) + + # Execute with non-matching credentials + result = [] + async for output_name, output_data in http_block.run( + input_data, + credentials=non_matching_credentials, + graph_exec_id="test-exec-id", + user_id="test-user-id", + ): + result.append((output_name, output_data)) + + # Verify only user headers are included (no credential headers) + mock_requests.request.assert_called_once() + call_args = mock_requests.request.call_args + expected_headers = {"User-Agent": "test-agent"} + assert call_args.kwargs["headers"] == expected_headers + + @pytest.mark.asyncio + @patch("backend.blocks.http.Requests") + async def test_user_headers_override_credential_headers( + self, + mock_requests_class, + http_block, + exact_match_credentials, + mock_response, + ): + """Test that user-provided headers take precedence over credential headers.""" + # Setup mocks + mock_requests = AsyncMock() + mock_requests.request.return_value = mock_response + mock_requests_class.return_value = mock_requests + + # Test with user header that conflicts with credential header + input_data = SendAuthenticatedWebRequestBlock.Input( + url="https://api.example.com/data", + method=HttpMethod.POST, + headers={ + "Authorization": "Bearer user-override-token", # Should override + "Content-Type": "application/json", # Additional user header + }, + credentials=cast( + HttpCredentials, + { + "id": exact_match_credentials.id, + "provider": "http", + "type": "host_scoped", + "title": exact_match_credentials.title, + }, + ), + ) + + # Execute with conflicting headers + result = [] + async for output_name, output_data in http_block.run( + input_data, + credentials=exact_match_credentials, + graph_exec_id="test-exec-id", + user_id="test-user-id", + ): + result.append((output_name, output_data)) + + # Verify user headers take precedence + mock_requests.request.assert_called_once() + call_args = mock_requests.request.call_args + expected_headers = { + "X-API-Key": "api-key-123", # From credentials + "Authorization": "Bearer user-override-token", # User override + "Content-Type": "application/json", # User header + } + assert call_args.kwargs["headers"] == expected_headers + + @pytest.mark.asyncio + @patch("backend.blocks.http.Requests") + async def test_auto_discovered_credentials_flow( + self, + mock_requests_class, + http_block, + mock_response, + ): + """Test the auto-discovery flow where execution manager provides matching credentials.""" + # Create auto-discovered credentials + auto_discovered_creds = HostScopedCredentials( + provider="http", + host="*.example.com", + headers={ + "Authorization": SecretStr("Bearer auto-discovered-token"), + }, + title="Auto-discovered Credentials", + ) + + # Setup mocks + mock_requests = AsyncMock() + mock_requests.request.return_value = mock_response + mock_requests_class.return_value = mock_requests + + # Test with empty credentials field (triggers auto-discovery) + input_data = SendAuthenticatedWebRequestBlock.Input( + url="https://api.example.com/data", + method=HttpMethod.GET, + headers={}, + credentials=cast( + HttpCredentials, + { + "id": "", # Empty ID triggers auto-discovery in execution manager + "provider": "http", + "type": "host_scoped", + "title": "", + }, + ), + ) + + # Execute with auto-discovered credentials provided by execution manager + result = [] + async for output_name, output_data in http_block.run( + input_data, + credentials=auto_discovered_creds, # Execution manager found these + graph_exec_id="test-exec-id", + user_id="test-user-id", + ): + result.append((output_name, output_data)) + + # Verify auto-discovered credentials were applied + mock_requests.request.assert_called_once() + call_args = mock_requests.request.call_args + expected_headers = {"Authorization": "Bearer auto-discovered-token"} + assert call_args.kwargs["headers"] == expected_headers + + # Verify response handling + assert len(result) == 1 + assert result[0][0] == "response" + assert result[0][1] == {"success": True, "data": "test"} + + @pytest.mark.asyncio + @patch("backend.blocks.http.Requests") + async def test_multiple_header_credentials( + self, + mock_requests_class, + http_block, + mock_response, + ): + """Test credentials with multiple headers are all applied.""" + # Create credentials with multiple headers + multi_header_creds = HostScopedCredentials( + provider="http", + host="api.example.com", + headers={ + "Authorization": SecretStr("Bearer multi-token"), + "X-API-Key": SecretStr("api-key-456"), + "X-Client-ID": SecretStr("client-789"), + "X-Custom-Header": SecretStr("custom-value"), + }, + title="Multi-Header Credentials", + ) + + # Setup mocks + mock_requests = AsyncMock() + mock_requests.request.return_value = mock_response + mock_requests_class.return_value = mock_requests + + # Test with credentials containing multiple headers + input_data = SendAuthenticatedWebRequestBlock.Input( + url="https://api.example.com/data", + method=HttpMethod.GET, + headers={"User-Agent": "test-agent"}, + credentials=cast( + HttpCredentials, + { + "id": multi_header_creds.id, + "provider": "http", + "type": "host_scoped", + "title": multi_header_creds.title, + }, + ), + ) + + # Execute with multi-header credentials + result = [] + async for output_name, output_data in http_block.run( + input_data, + credentials=multi_header_creds, + graph_exec_id="test-exec-id", + user_id="test-user-id", + ): + result.append((output_name, output_data)) + + # Verify all headers are included + mock_requests.request.assert_called_once() + call_args = mock_requests.request.call_args + expected_headers = { + "Authorization": "Bearer multi-token", + "X-API-Key": "api-key-456", + "X-Client-ID": "client-789", + "X-Custom-Header": "custom-value", + "User-Agent": "test-agent", + } + assert call_args.kwargs["headers"] == expected_headers + + @pytest.mark.asyncio + @patch("backend.blocks.http.Requests") + async def test_credentials_with_complex_url_patterns( + self, + mock_requests_class, + http_block, + mock_response, + ): + """Test credentials matching various URL patterns.""" + # Test cases for different URL patterns + test_cases = [ + { + "host_pattern": "api.example.com", + "test_url": "https://api.example.com/v1/users", + "should_match": True, + }, + { + "host_pattern": "*.example.com", + "test_url": "https://api.example.com/v1/users", + "should_match": True, + }, + { + "host_pattern": "*.example.com", + "test_url": "https://subdomain.example.com/data", + "should_match": True, + }, + { + "host_pattern": "api.example.com", + "test_url": "https://api.different.com/data", + "should_match": False, + }, + ] + + # Setup mocks + mock_requests = AsyncMock() + mock_requests.request.return_value = mock_response + mock_requests_class.return_value = mock_requests + + for case in test_cases: + # Reset mock for each test case + mock_requests.reset_mock() + + # Create credentials for this test case + test_creds = HostScopedCredentials( + provider="http", + host=case["host_pattern"], + headers={ + "Authorization": SecretStr(f"Bearer {case['host_pattern']}-token"), + }, + title=f"Credentials for {case['host_pattern']}", + ) + + input_data = SendAuthenticatedWebRequestBlock.Input( + url=case["test_url"], + method=HttpMethod.GET, + headers={"User-Agent": "test-agent"}, + credentials=cast( + HttpCredentials, + { + "id": test_creds.id, + "provider": "http", + "type": "host_scoped", + "title": test_creds.title, + }, + ), + ) + + # Execute with test credentials + result = [] + async for output_name, output_data in http_block.run( + input_data, + credentials=test_creds, + graph_exec_id="test-exec-id", + user_id="test-user-id", + ): + result.append((output_name, output_data)) + + # Verify headers based on whether pattern should match + mock_requests.request.assert_called_once() + call_args = mock_requests.request.call_args + headers = call_args.kwargs["headers"] + + if case["should_match"]: + # Should include both user and credential headers + expected_auth = f"Bearer {case['host_pattern']}-token" + assert headers["Authorization"] == expected_auth + assert headers["User-Agent"] == "test-agent" + else: + # Should only include user headers + assert "Authorization" not in headers + assert headers["User-Agent"] == "test-agent" diff --git a/autogpt_platform/backend/backend/blocks/test/test_llm.py b/autogpt_platform/backend/backend/blocks/test/test_llm.py new file mode 100644 index 000000000000..f77251d754fb --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/test/test_llm.py @@ -0,0 +1,492 @@ +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.data.model import NodeExecutionStats + + +class TestLLMStatsTracking: + """Test that LLM blocks correctly track token usage statistics.""" + + @pytest.mark.asyncio + async def test_llm_call_returns_token_counts(self): + """Test that llm_call returns proper token counts in LLMResponse.""" + import backend.blocks.llm as llm + + # Mock the OpenAI client + mock_response = MagicMock() + mock_response.choices = [ + MagicMock(message=MagicMock(content="Test response", tool_calls=None)) + ] + mock_response.usage = MagicMock(prompt_tokens=10, completion_tokens=20) + + # Test with mocked OpenAI response + with patch("openai.AsyncOpenAI") as mock_openai: + mock_client = AsyncMock() + mock_openai.return_value = mock_client + mock_client.chat.completions.create = AsyncMock(return_value=mock_response) + + response = await llm.llm_call( + credentials=llm.TEST_CREDENTIALS, + llm_model=llm.LlmModel.GPT4O, + prompt=[{"role": "user", "content": "Hello"}], + json_format=False, + max_tokens=100, + ) + + assert isinstance(response, llm.LLMResponse) + assert response.prompt_tokens == 10 + assert response.completion_tokens == 20 + assert response.response == "Test response" + + @pytest.mark.asyncio + async def test_ai_structured_response_block_tracks_stats(self): + """Test that AIStructuredResponseGeneratorBlock correctly tracks stats.""" + import backend.blocks.llm as llm + + block = llm.AIStructuredResponseGeneratorBlock() + + # Mock the llm_call method + async def mock_llm_call(*args, **kwargs): + return llm.LLMResponse( + raw_response="", + prompt=[], + response='{"key1": "value1", "key2": "value2"}', + tool_calls=None, + prompt_tokens=15, + completion_tokens=25, + reasoning=None, + ) + + block.llm_call = mock_llm_call # type: ignore + + # Run the block + input_data = llm.AIStructuredResponseGeneratorBlock.Input( + prompt="Test prompt", + expected_format={"key1": "desc1", "key2": "desc2"}, + model=llm.LlmModel.GPT4O, + credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore # type: ignore + ) + + outputs = {} + async for output_name, output_data in block.run( + input_data, credentials=llm.TEST_CREDENTIALS + ): + outputs[output_name] = output_data + + # Check stats + assert block.execution_stats.input_token_count == 15 + assert block.execution_stats.output_token_count == 25 + assert block.execution_stats.llm_call_count == 1 + assert block.execution_stats.llm_retry_count == 0 + + # Check output + assert "response" in outputs + assert outputs["response"] == {"key1": "value1", "key2": "value2"} + + @pytest.mark.asyncio + async def test_ai_text_generator_block_tracks_stats(self): + """Test that AITextGeneratorBlock correctly tracks stats through delegation.""" + import backend.blocks.llm as llm + + block = llm.AITextGeneratorBlock() + + # Mock the underlying structured response block + async def mock_llm_call(input_data, credentials): + # Simulate the structured block setting stats + block.execution_stats = NodeExecutionStats( + input_token_count=30, + output_token_count=40, + llm_call_count=1, + ) + return "Generated text" # AITextGeneratorBlock.llm_call returns a string + + block.llm_call = mock_llm_call # type: ignore + + # Run the block + input_data = llm.AITextGeneratorBlock.Input( + prompt="Generate text", + model=llm.LlmModel.GPT4O, + credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore + ) + + outputs = {} + async for output_name, output_data in block.run( + input_data, credentials=llm.TEST_CREDENTIALS + ): + outputs[output_name] = output_data + + # Check stats + assert block.execution_stats.input_token_count == 30 + assert block.execution_stats.output_token_count == 40 + assert block.execution_stats.llm_call_count == 1 + + # Check output - AITextGeneratorBlock returns the response directly, not in a dict + assert outputs["response"] == "Generated text" + + @pytest.mark.asyncio + async def test_stats_accumulation_with_retries(self): + """Test that stats correctly accumulate across retries.""" + import backend.blocks.llm as llm + + block = llm.AIStructuredResponseGeneratorBlock() + + # Counter to track calls + call_count = 0 + + async def mock_llm_call(*args, **kwargs): + nonlocal call_count + call_count += 1 + + # First call returns invalid format + if call_count == 1: + return llm.LLMResponse( + raw_response="", + prompt=[], + response='{"wrong": "format"}', + tool_calls=None, + prompt_tokens=10, + completion_tokens=15, + reasoning=None, + ) + # Second call returns correct format + else: + return llm.LLMResponse( + raw_response="", + prompt=[], + response='{"key1": "value1", "key2": "value2"}', + tool_calls=None, + prompt_tokens=20, + completion_tokens=25, + reasoning=None, + ) + + block.llm_call = mock_llm_call # type: ignore + + # Run the block with retry + input_data = llm.AIStructuredResponseGeneratorBlock.Input( + prompt="Test prompt", + expected_format={"key1": "desc1", "key2": "desc2"}, + model=llm.LlmModel.GPT4O, + credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore + retry=2, + ) + + outputs = {} + async for output_name, output_data in block.run( + input_data, credentials=llm.TEST_CREDENTIALS + ): + outputs[output_name] = output_data + + # Check stats - should accumulate both calls + # For 2 attempts: attempt 1 (failed) + attempt 2 (success) = 2 total + # but llm_call_count is only set on success, so it shows 1 for the final successful attempt + assert block.execution_stats.input_token_count == 30 # 10 + 20 + assert block.execution_stats.output_token_count == 40 # 15 + 25 + assert block.execution_stats.llm_call_count == 2 # retry_count + 1 = 1 + 1 = 2 + assert block.execution_stats.llm_retry_count == 1 + + @pytest.mark.asyncio + async def test_ai_text_summarizer_multiple_chunks(self): + """Test that AITextSummarizerBlock correctly accumulates stats across multiple chunks.""" + import backend.blocks.llm as llm + + block = llm.AITextSummarizerBlock() + + # Track calls to simulate multiple chunks + call_count = 0 + + async def mock_llm_call(input_data, credentials): + nonlocal call_count + call_count += 1 + + # Create a mock block with stats to merge from + mock_structured_block = llm.AIStructuredResponseGeneratorBlock() + mock_structured_block.execution_stats = NodeExecutionStats( + input_token_count=25, + output_token_count=15, + llm_call_count=1, + ) + + # Simulate merge_llm_stats behavior + block.merge_llm_stats(mock_structured_block) + + if "final_summary" in input_data.expected_format: + return {"final_summary": "Final combined summary"} + else: + return {"summary": f"Summary of chunk {call_count}"} + + block.llm_call = mock_llm_call # type: ignore + + # Create long text that will be split into chunks + long_text = " ".join(["word"] * 1000) # Moderate size to force ~2-3 chunks + + input_data = llm.AITextSummarizerBlock.Input( + text=long_text, + model=llm.LlmModel.GPT4O, + credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore + max_tokens=100, # Small chunks + chunk_overlap=10, + ) + + # Run the block + outputs = {} + async for output_name, output_data in block.run( + input_data, credentials=llm.TEST_CREDENTIALS + ): + outputs[output_name] = output_data + + # Block finished - now grab and assert stats + assert block.execution_stats is not None + assert call_count > 1 # Should have made multiple calls + assert block.execution_stats.llm_call_count > 0 + assert block.execution_stats.input_token_count > 0 + assert block.execution_stats.output_token_count > 0 + + # Check output + assert "summary" in outputs + assert outputs["summary"] == "Final combined summary" + + @pytest.mark.asyncio + async def test_ai_text_summarizer_real_llm_call_stats(self): + """Test AITextSummarizer with real LLM call mocking to verify llm_call_count.""" + from unittest.mock import AsyncMock, MagicMock, patch + + import backend.blocks.llm as llm + + block = llm.AITextSummarizerBlock() + + # Mock the actual LLM call instead of the llm_call method + call_count = 0 + + async def mock_create(*args, **kwargs): + nonlocal call_count + call_count += 1 + + mock_response = MagicMock() + # Return different responses for chunk summary vs final summary + if call_count == 1: + mock_response.choices = [ + MagicMock( + message=MagicMock( + content='{"summary": "Test chunk summary"}', tool_calls=None + ) + ) + ] + else: + mock_response.choices = [ + MagicMock( + message=MagicMock( + content='{"final_summary": "Test final summary"}', + tool_calls=None, + ) + ) + ] + mock_response.usage = MagicMock(prompt_tokens=50, completion_tokens=30) + return mock_response + + with patch("openai.AsyncOpenAI") as mock_openai: + mock_client = AsyncMock() + mock_openai.return_value = mock_client + mock_client.chat.completions.create = mock_create + + # Test with very short text (should only need 1 chunk + 1 final summary) + input_data = llm.AITextSummarizerBlock.Input( + text="This is a short text.", + model=llm.LlmModel.GPT4O, + credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore + max_tokens=1000, # Large enough to avoid chunking + ) + + outputs = {} + async for output_name, output_data in block.run( + input_data, credentials=llm.TEST_CREDENTIALS + ): + outputs[output_name] = output_data + + print(f"Actual calls made: {call_count}") + print(f"Block stats: {block.execution_stats}") + print(f"LLM call count: {block.execution_stats.llm_call_count}") + + # Should have made 2 calls: 1 for chunk summary + 1 for final summary + assert block.execution_stats.llm_call_count >= 1 + assert block.execution_stats.input_token_count > 0 + assert block.execution_stats.output_token_count > 0 + + @pytest.mark.asyncio + async def test_ai_conversation_block_tracks_stats(self): + """Test that AIConversationBlock correctly tracks stats.""" + import backend.blocks.llm as llm + + block = llm.AIConversationBlock() + + # Mock the llm_call method + async def mock_llm_call(input_data, credentials): + block.execution_stats = NodeExecutionStats( + input_token_count=100, + output_token_count=50, + llm_call_count=1, + ) + return {"response": "AI response to conversation"} + + block.llm_call = mock_llm_call # type: ignore + + # Run the block + input_data = llm.AIConversationBlock.Input( + messages=[ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi there!"}, + {"role": "user", "content": "How are you?"}, + ], + model=llm.LlmModel.GPT4O, + credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore + ) + + outputs = {} + async for output_name, output_data in block.run( + input_data, credentials=llm.TEST_CREDENTIALS + ): + outputs[output_name] = output_data + + # Check stats + assert block.execution_stats.input_token_count == 100 + assert block.execution_stats.output_token_count == 50 + assert block.execution_stats.llm_call_count == 1 + + # Check output + assert outputs["response"] == {"response": "AI response to conversation"} + + @pytest.mark.asyncio + async def test_ai_list_generator_with_retries(self): + """Test that AIListGeneratorBlock correctly tracks stats with retries.""" + import backend.blocks.llm as llm + + block = llm.AIListGeneratorBlock() + + # Counter to track calls + call_count = 0 + + async def mock_llm_call(input_data, credentials): + nonlocal call_count + call_count += 1 + + # Update stats + if hasattr(block, "execution_stats") and block.execution_stats: + block.execution_stats.input_token_count += 40 + block.execution_stats.output_token_count += 20 + block.execution_stats.llm_call_count += 1 + else: + block.execution_stats = NodeExecutionStats( + input_token_count=40, + output_token_count=20, + llm_call_count=1, + ) + + if call_count == 1: + # First call returns invalid format + return {"response": "not a valid list"} + else: + # Second call returns valid list + return {"response": "['item1', 'item2', 'item3']"} + + block.llm_call = mock_llm_call # type: ignore + + # Run the block + input_data = llm.AIListGeneratorBlock.Input( + focus="test items", + model=llm.LlmModel.GPT4O, + credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore + max_retries=3, + ) + + outputs = {} + async for output_name, output_data in block.run( + input_data, credentials=llm.TEST_CREDENTIALS + ): + outputs[output_name] = output_data + + # Check stats - should have 2 calls + assert call_count == 2 + assert block.execution_stats.input_token_count == 80 # 40 * 2 + assert block.execution_stats.output_token_count == 40 # 20 * 2 + assert block.execution_stats.llm_call_count == 2 + + # Check output + assert outputs["generated_list"] == ["item1", "item2", "item3"] + + @pytest.mark.asyncio + async def test_merge_llm_stats(self): + """Test the merge_llm_stats method correctly merges stats from another block.""" + import backend.blocks.llm as llm + + block1 = llm.AITextGeneratorBlock() + block2 = llm.AIStructuredResponseGeneratorBlock() + + # Set stats on block2 + block2.execution_stats = NodeExecutionStats( + input_token_count=100, + output_token_count=50, + llm_call_count=2, + llm_retry_count=1, + ) + block2.prompt = [{"role": "user", "content": "Test"}] + + # Merge stats from block2 into block1 + block1.merge_llm_stats(block2) + + # Check that stats were merged + assert block1.execution_stats.input_token_count == 100 + assert block1.execution_stats.output_token_count == 50 + assert block1.execution_stats.llm_call_count == 2 + assert block1.execution_stats.llm_retry_count == 1 + assert block1.prompt == [{"role": "user", "content": "Test"}] + + @pytest.mark.asyncio + async def test_stats_initialization(self): + """Test that blocks properly initialize stats when not present.""" + import backend.blocks.llm as llm + + block = llm.AIStructuredResponseGeneratorBlock() + + # Initially stats should be initialized with zeros + assert hasattr(block, "execution_stats") + assert block.execution_stats.llm_call_count == 0 + + # Mock llm_call + async def mock_llm_call(*args, **kwargs): + return llm.LLMResponse( + raw_response="", + prompt=[], + response='{"result": "test"}', + tool_calls=None, + prompt_tokens=10, + completion_tokens=20, + reasoning=None, + ) + + block.llm_call = mock_llm_call # type: ignore + + # Run the block + input_data = llm.AIStructuredResponseGeneratorBlock.Input( + prompt="Test", + expected_format={"result": "desc"}, + model=llm.LlmModel.GPT4O, + credentials=llm.TEST_CREDENTIALS_INPUT, # type: ignore + ) + + # Run the block + outputs = {} + async for output_name, output_data in block.run( + input_data, credentials=llm.TEST_CREDENTIALS + ): + outputs[output_name] = output_data + + # Block finished - now grab and assert stats + assert block.execution_stats is not None + assert block.execution_stats.input_token_count == 10 + assert block.execution_stats.output_token_count == 20 + assert block.execution_stats.llm_call_count == 1 # Should have exactly 1 call + + # Check output + assert "response" in outputs + assert outputs["response"] == {"result": "test"} diff --git a/autogpt_platform/backend/backend/blocks/test/test_smart_decision_maker.py b/autogpt_platform/backend/backend/blocks/test/test_smart_decision_maker.py new file mode 100644 index 000000000000..c09eac09e165 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/test/test_smart_decision_maker.py @@ -0,0 +1,252 @@ +import logging + +import pytest + +from backend.data.model import ProviderName, User +from backend.server.model import CreateGraph +from backend.server.rest_api import AgentServer +from backend.usecases.sample import create_test_graph, create_test_user +from backend.util.test import SpinTestServer, wait_execution + +logger = logging.getLogger(__name__) + + +async def create_graph(s: SpinTestServer, g, u: User): + logger.info("Creating graph for user %s", u.id) + return await s.agent_server.test_create_graph(CreateGraph(graph=g), u.id) + + +async def create_credentials(s: SpinTestServer, u: User): + import backend.blocks.llm as llm + + provider = ProviderName.OPENAI + credentials = llm.TEST_CREDENTIALS + return await s.agent_server.test_create_credentials(u.id, provider, credentials) + + +async def execute_graph( + agent_server: AgentServer, + test_graph, + test_user: User, + input_data: dict, + num_execs: int = 4, +) -> str: + logger.info("Executing graph %s for user %s", test_graph.id, test_user.id) + logger.info("Input data: %s", input_data) + + # --- Test adding new executions --- # + response = await agent_server.test_execute_graph( + user_id=test_user.id, + graph_id=test_graph.id, + graph_version=test_graph.version, + node_input=input_data, + ) + graph_exec_id = response.graph_exec_id + logger.info("Created execution with ID: %s", graph_exec_id) + + # Execution queue should be empty + logger.info("Waiting for execution to complete...") + result = await wait_execution(test_user.id, graph_exec_id, 30) + logger.info("Execution completed with %d results", len(result)) + return graph_exec_id + + +@pytest.mark.asyncio(loop_scope="session") +async def test_graph_validation_with_tool_nodes_correct(server: SpinTestServer): + from backend.blocks.agent import AgentExecutorBlock + from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock + from backend.data import graph + + test_user = await create_test_user() + test_tool_graph = await create_graph(server, create_test_graph(), test_user) + creds = await create_credentials(server, test_user) + + nodes = [ + graph.Node( + block_id=SmartDecisionMakerBlock().id, + input_default={ + "prompt": "Hello, World!", + "credentials": creds, + }, + ), + graph.Node( + block_id=AgentExecutorBlock().id, + input_default={ + "graph_id": test_tool_graph.id, + "graph_version": test_tool_graph.version, + "input_schema": test_tool_graph.input_schema, + "output_schema": test_tool_graph.output_schema, + }, + ), + ] + + links = [ + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[1].id, + source_name="tools_^_sample_tool_input_1", + sink_name="input_1", + ), + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[1].id, + source_name="tools_^_sample_tool_input_2", + sink_name="input_2", + ), + ] + + test_graph = graph.Graph( + name="TestGraph", + description="Test graph", + nodes=nodes, + links=links, + ) + test_graph = await create_graph(server, test_graph, test_user) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_smart_decision_maker_function_signature(server: SpinTestServer): + from backend.blocks.agent import AgentExecutorBlock + from backend.blocks.basic import StoreValueBlock + from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock + from backend.data import graph + + test_user = await create_test_user() + test_tool_graph = await create_graph(server, create_test_graph(), test_user) + creds = await create_credentials(server, test_user) + + nodes = [ + graph.Node( + block_id=SmartDecisionMakerBlock().id, + input_default={ + "prompt": "Hello, World!", + "credentials": creds, + }, + ), + graph.Node( + block_id=AgentExecutorBlock().id, + input_default={ + "graph_id": test_tool_graph.id, + "graph_version": test_tool_graph.version, + "input_schema": test_tool_graph.input_schema, + "output_schema": test_tool_graph.output_schema, + }, + ), + graph.Node( + block_id=StoreValueBlock().id, + ), + ] + + links = [ + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[1].id, + source_name="tools_^_sample_tool_input_1", + sink_name="input_1", + ), + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[1].id, + source_name="tools_^_sample_tool_input_2", + sink_name="input_2", + ), + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[2].id, + source_name="tools_^_store_value_input", + sink_name="input", + ), + ] + + test_graph = graph.Graph( + name="TestGraph", + description="Test graph", + nodes=nodes, + links=links, + ) + test_graph = await create_graph(server, test_graph, test_user) + + tool_functions = await SmartDecisionMakerBlock._create_function_signature( + test_graph.nodes[0].id + ) + assert tool_functions is not None, "Tool functions should not be None" + + assert ( + len(tool_functions) == 2 + ), f"Expected 2 tool functions, got {len(tool_functions)}" + + # Check the first tool function (testgraph) + assert tool_functions[0]["type"] == "function" + assert tool_functions[0]["function"]["name"] == "testgraph" + assert tool_functions[0]["function"]["description"] == "Test graph description" + assert "input_1" in tool_functions[0]["function"]["parameters"]["properties"] + assert "input_2" in tool_functions[0]["function"]["parameters"]["properties"] + + # Check the second tool function (storevalueblock) + assert tool_functions[1]["type"] == "function" + assert tool_functions[1]["function"]["name"] == "storevalueblock" + assert "input" in tool_functions[1]["function"]["parameters"]["properties"] + assert ( + tool_functions[1]["function"]["parameters"]["properties"]["input"][ + "description" + ] + == "Trigger the block to produce the output. The value is only used when `data` is None." + ) + + +@pytest.mark.asyncio +async def test_smart_decision_maker_tracks_llm_stats(): + """Test that SmartDecisionMakerBlock correctly tracks LLM usage stats.""" + from unittest.mock import MagicMock, patch + + import backend.blocks.llm as llm_module + from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock + + block = SmartDecisionMakerBlock() + + # Mock the llm.llm_call function to return controlled data + mock_response = MagicMock() + mock_response.response = "I need to think about this." + mock_response.tool_calls = None # No tool calls for simplicity + mock_response.prompt_tokens = 50 + mock_response.completion_tokens = 25 + mock_response.reasoning = None + mock_response.raw_response = { + "role": "assistant", + "content": "I need to think about this.", + } + + # Mock the _create_function_signature method to avoid database calls + with patch("backend.blocks.llm.llm_call", return_value=mock_response), patch.object( + SmartDecisionMakerBlock, "_create_function_signature", return_value=[] + ): + + # Create test input + input_data = SmartDecisionMakerBlock.Input( + prompt="Should I continue with this task?", + model=llm_module.LlmModel.GPT4O, + credentials=llm_module.TEST_CREDENTIALS_INPUT, # type: ignore + ) + + # Execute the block + outputs = {} + async for output_name, output_data in block.run( + input_data, + credentials=llm_module.TEST_CREDENTIALS, + graph_id="test-graph-id", + node_id="test-node-id", + graph_exec_id="test-exec-id", + node_exec_id="test-node-exec-id", + user_id="test-user-id", + ): + outputs[output_name] = output_data + + # Verify stats tracking + assert block.execution_stats is not None + assert block.execution_stats.input_token_count == 50 + assert block.execution_stats.output_token_count == 25 + assert block.execution_stats.llm_call_count == 1 + + # Verify outputs + assert "finished" in outputs # Should have finished since no tool calls + assert outputs["finished"] == "I need to think about this." diff --git a/autogpt_platform/backend/backend/blocks/test/test_smart_decision_maker_dict.py b/autogpt_platform/backend/backend/blocks/test/test_smart_decision_maker_dict.py new file mode 100644 index 000000000000..0b405b3fcd4e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/test/test_smart_decision_maker_dict.py @@ -0,0 +1,130 @@ +from unittest.mock import Mock + +import pytest + +from backend.blocks.data_manipulation import AddToListBlock, CreateDictionaryBlock +from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock + + +@pytest.mark.asyncio +async def test_smart_decision_maker_handles_dynamic_dict_fields(): + """Test Smart Decision Maker can handle dynamic dictionary fields (_#_) for any block""" + + # Create a mock node for CreateDictionaryBlock + mock_node = Mock() + mock_node.block = CreateDictionaryBlock() + mock_node.block_id = CreateDictionaryBlock().id + mock_node.input_default = {} + + # Create mock links with dynamic dictionary fields + mock_links = [ + Mock( + source_name="tools_^_create_dict_~_name", + sink_name="values_#_name", # Dynamic dict field + sink_id="dict_node_id", + source_id="smart_decision_node_id", + ), + Mock( + source_name="tools_^_create_dict_~_age", + sink_name="values_#_age", # Dynamic dict field + sink_id="dict_node_id", + source_id="smart_decision_node_id", + ), + Mock( + source_name="tools_^_create_dict_~_city", + sink_name="values_#_city", # Dynamic dict field + sink_id="dict_node_id", + source_id="smart_decision_node_id", + ), + ] + + # Generate function signature + signature = await SmartDecisionMakerBlock._create_block_function_signature( + mock_node, mock_links # type: ignore + ) + + # Verify the signature was created successfully + assert signature["type"] == "function" + assert "parameters" in signature["function"] + assert "properties" in signature["function"]["parameters"] + + # Check that dynamic fields are handled + properties = signature["function"]["parameters"]["properties"] + assert len(properties) == 3 # Should have all three fields + + # Each dynamic field should have proper schema + for prop_value in properties.values(): + assert "type" in prop_value + assert prop_value["type"] == "string" # Dynamic fields get string type + assert "description" in prop_value + assert "Dynamic value for" in prop_value["description"] + + +@pytest.mark.asyncio +async def test_smart_decision_maker_handles_dynamic_list_fields(): + """Test Smart Decision Maker can handle dynamic list fields (_$_) for any block""" + + # Create a mock node for AddToListBlock + mock_node = Mock() + mock_node.block = AddToListBlock() + mock_node.block_id = AddToListBlock().id + mock_node.input_default = {} + + # Create mock links with dynamic list fields + mock_links = [ + Mock( + source_name="tools_^_add_to_list_~_0", + sink_name="entries_$_0", # Dynamic list field + sink_id="list_node_id", + source_id="smart_decision_node_id", + ), + Mock( + source_name="tools_^_add_to_list_~_1", + sink_name="entries_$_1", # Dynamic list field + sink_id="list_node_id", + source_id="smart_decision_node_id", + ), + ] + + # Generate function signature + signature = await SmartDecisionMakerBlock._create_block_function_signature( + mock_node, mock_links # type: ignore + ) + + # Verify dynamic list fields are handled properly + assert signature["type"] == "function" + properties = signature["function"]["parameters"]["properties"] + assert len(properties) == 2 # Should have both list items + + # Each dynamic field should have proper schema + for prop_value in properties.values(): + assert prop_value["type"] == "string" + assert "Dynamic value for" in prop_value["description"] + + +@pytest.mark.asyncio +async def test_create_dict_block_with_dynamic_values(): + """Test CreateDictionaryBlock processes dynamic values correctly""" + + block = CreateDictionaryBlock() + + # Simulate what happens when executor merges dynamic fields + # The executor merges values_#_* fields into the values dict + input_data = block.input_schema( + values={ + "existing": "value", + "name": "Alice", # This would come from values_#_name + "age": 25, # This would come from values_#_age + } + ) + + # Run the block + result = {} + async for output_name, output_value in block.run(input_data): + result[output_name] = output_value + + # Check the result + assert "dictionary" in result + assert result["dictionary"]["existing"] == "value" + assert result["dictionary"]["name"] == "Alice" + assert result["dictionary"]["age"] == 25 diff --git a/autogpt_platform/backend/backend/blocks/test/test_store_operations.py b/autogpt_platform/backend/backend/blocks/test/test_store_operations.py new file mode 100644 index 000000000000..088d0d60e582 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/test/test_store_operations.py @@ -0,0 +1,155 @@ +from unittest.mock import MagicMock + +import pytest + +from backend.blocks.system.library_operations import ( + AddToLibraryFromStoreBlock, + LibraryAgent, +) +from backend.blocks.system.store_operations import ( + GetStoreAgentDetailsBlock, + SearchAgentsResponse, + SearchStoreAgentsBlock, + StoreAgentDetails, + StoreAgentDict, +) + + +@pytest.mark.asyncio +async def test_add_to_library_from_store_block_success(mocker): + """Test successful addition of agent from store to library.""" + block = AddToLibraryFromStoreBlock() + + # Mock the library agent response + mock_library_agent = MagicMock() + mock_library_agent.id = "lib-agent-123" + mock_library_agent.graph_id = "graph-456" + mock_library_agent.graph_version = 1 + mock_library_agent.name = "Test Agent" + + mocker.patch.object( + block, + "_add_to_library", + return_value=LibraryAgent( + library_agent_id="lib-agent-123", + agent_id="graph-456", + agent_version=1, + agent_name="Test Agent", + ), + ) + + input_data = block.Input( + store_listing_version_id="store-listing-v1", agent_name="Custom Agent Name" + ) + + outputs = {} + async for name, value in block.run(input_data, user_id="test-user"): + outputs[name] = value + + assert outputs["success"] is True + assert outputs["library_agent_id"] == "lib-agent-123" + assert outputs["agent_id"] == "graph-456" + assert outputs["agent_version"] == 1 + assert outputs["agent_name"] == "Test Agent" + assert outputs["message"] == "Agent successfully added to library" + + +@pytest.mark.asyncio +async def test_get_store_agent_details_block_success(mocker): + """Test successful retrieval of store agent details.""" + block = GetStoreAgentDetailsBlock() + + mocker.patch.object( + block, + "_get_agent_details", + return_value=StoreAgentDetails( + found=True, + store_listing_version_id="version-123", + agent_name="Test Agent", + description="A test agent for testing", + creator="Test Creator", + categories=["productivity", "automation"], + runs=100, + rating=4.5, + ), + ) + + input_data = block.Input(creator="Test Creator", slug="test-slug") + outputs = {} + async for name, value in block.run(input_data): + outputs[name] = value + + assert outputs["found"] is True + assert outputs["store_listing_version_id"] == "version-123" + assert outputs["agent_name"] == "Test Agent" + assert outputs["description"] == "A test agent for testing" + assert outputs["creator"] == "Test Creator" + assert outputs["categories"] == ["productivity", "automation"] + assert outputs["runs"] == 100 + assert outputs["rating"] == 4.5 + + +@pytest.mark.asyncio +async def test_search_store_agents_block(mocker): + """Test searching for store agents.""" + block = SearchStoreAgentsBlock() + + mocker.patch.object( + block, + "_search_agents", + return_value=SearchAgentsResponse( + agents=[ + StoreAgentDict( + slug="creator1/agent1", + name="Agent One", + description="First test agent", + creator="Creator 1", + rating=4.8, + runs=500, + ), + StoreAgentDict( + slug="creator2/agent2", + name="Agent Two", + description="Second test agent", + creator="Creator 2", + rating=4.2, + runs=200, + ), + ], + total_count=2, + ), + ) + + input_data = block.Input( + query="test", category="productivity", sort_by="rating", limit=10 + ) + + outputs = {} + async for name, value in block.run(input_data): + outputs[name] = value + + assert len(outputs["agents"]) == 2 + assert outputs["total_count"] == 2 + assert outputs["agents"][0]["name"] == "Agent One" + assert outputs["agents"][0]["rating"] == 4.8 + + +@pytest.mark.asyncio +async def test_search_store_agents_block_empty_results(mocker): + """Test searching with no results.""" + block = SearchStoreAgentsBlock() + + mocker.patch.object( + block, + "_search_agents", + return_value=SearchAgentsResponse(agents=[], total_count=0), + ) + + input_data = block.Input(query="nonexistent", limit=10) + + outputs = {} + async for name, value in block.run(input_data): + outputs[name] = value + + assert outputs["agents"] == [] + assert outputs["total_count"] == 0 diff --git a/autogpt_platform/backend/backend/blocks/text.py b/autogpt_platform/backend/backend/blocks/text.py index d1e59cb83163..fc586430fc03 100644 --- a/autogpt_platform/backend/backend/blocks/text.py +++ b/autogpt_platform/backend/backend/blocks/text.py @@ -1,9 +1,12 @@ import re +from pathlib import Path from typing import Any from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema from backend.data.model import SchemaField from backend.util import json, text +from backend.util.file import get_exec_file_path, store_media_file +from backend.util.type import MediaFileType formatter = text.TextFormatter() @@ -43,7 +46,7 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: output = input_data.data or input_data.text flags = 0 if not input_data.case_sensitive: @@ -76,6 +79,8 @@ class Input(BlockSchema): class Output(BlockSchema): positive: str = SchemaField(description="Extracted text") negative: str = SchemaField(description="Original text") + matched_results: list[str] = SchemaField(description="List of matched results") + matched_count: int = SchemaField(description="Number of matched results") def __init__(self): super().__init__( @@ -103,17 +108,35 @@ def __init__(self): }, ], test_output=[ + # Test case 1 ("positive", "World!"), + ("matched_results", ["World!"]), + ("matched_count", 1), + # Test case 2 ("positive", "Hello, World!"), + ("matched_results", ["Hello, World!"]), + ("matched_count", 1), + # Test case 3 ("negative", "Hello, World!"), + ("matched_results", []), + ("matched_count", 0), + # Test case 4 ("positive", "Hello,"), + ("matched_results", ["Hello,"]), + ("matched_count", 1), + # Test case 5 ("positive", "World!!"), + ("matched_results", ["World!!"]), + ("matched_count", 1), + # Test case 6 ("positive", "World!!"), ("positive", "Earth!!"), + ("matched_results", ["World!!", "Earth!!"]), + ("matched_count", 2), ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: flags = 0 if not input_data.case_sensitive: flags = flags | re.IGNORECASE @@ -130,21 +153,24 @@ def run(self, input_data: Input, **kwargs) -> BlockOutput: for match in re.finditer(input_data.pattern, txt, flags) if input_data.group <= len(match.groups()) ] + if not input_data.find_all: + matches = matches[:1] for match in matches: yield "positive", match - if not input_data.find_all: - return if not matches: yield "negative", input_data.text + yield "matched_results", matches + yield "matched_count", len(matches) + class FillTextTemplateBlock(Block): class Input(BlockSchema): values: dict[str, Any] = SchemaField( - description="Values (dict) to be used in format" + description="Values (dict) to be used in format. These values can be used by putting them in double curly braces in the format template. e.g. {{value_name}}.", ) format: str = SchemaField( - description="Template to format the text using `values`" + description="Template to format the text using `values`. Use Jinja2 syntax." ) class Output(BlockSchema): @@ -160,7 +186,7 @@ def __init__(self): test_input=[ { "values": {"name": "Alice", "hello": "Hello", "world": "World!"}, - "format": "{hello}, {world} {{name}}", + "format": "{{hello}}, {{ world }} {{name}}", }, { "values": {"list": ["Hello", " World!"]}, @@ -178,7 +204,7 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: yield "output", formatter.format_string(input_data.format, input_data.values) @@ -209,6 +235,200 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: combined_text = input_data.delimiter.join(input_data.input) yield "output", combined_text + + +class TextSplitBlock(Block): + class Input(BlockSchema): + text: str = SchemaField(description="The text to split.") + delimiter: str = SchemaField(description="The delimiter to split the text by.") + strip: bool = SchemaField( + description="Whether to strip the text.", default=True + ) + + class Output(BlockSchema): + texts: list[str] = SchemaField( + description="The text split into a list of strings." + ) + + def __init__(self): + super().__init__( + id="d5ea33c8-a575-477a-b42f-2fe3be5055ec", + description="This block is used to split a text into a list of strings.", + categories={BlockCategory.TEXT}, + input_schema=TextSplitBlock.Input, + output_schema=TextSplitBlock.Output, + test_input=[ + {"text": "Hello, World!", "delimiter": ","}, + {"text": "Hello, World!", "delimiter": ",", "strip": False}, + ], + test_output=[ + ("texts", ["Hello", "World!"]), + ("texts", ["Hello", " World!"]), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + if len(input_data.text) == 0: + yield "texts", [] + else: + texts = input_data.text.split(input_data.delimiter) + if input_data.strip: + texts = [text.strip() for text in texts] + yield "texts", texts + + +class TextReplaceBlock(Block): + class Input(BlockSchema): + text: str = SchemaField(description="The text to replace.") + old: str = SchemaField(description="The old text to replace.") + new: str = SchemaField(description="The new text to replace with.") + + class Output(BlockSchema): + output: str = SchemaField(description="The text with the replaced text.") + + def __init__(self): + super().__init__( + id="7e7c87ab-3469-4bcc-9abe-67705091b713", + description="This block is used to replace a text with a new text.", + categories={BlockCategory.TEXT}, + input_schema=TextReplaceBlock.Input, + output_schema=TextReplaceBlock.Output, + test_input=[ + {"text": "Hello, World!", "old": "Hello", "new": "Hi"}, + ], + test_output=[ + ("output", "Hi, World!"), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + yield "output", input_data.text.replace(input_data.old, input_data.new) + + +class FileReadBlock(Block): + class Input(BlockSchema): + file_input: MediaFileType = SchemaField( + description="The file to read from (URL, data URI, or local path)" + ) + delimiter: str = SchemaField( + description="Delimiter to split the content into rows/chunks (e.g., '\\n' for lines)", + default="", + advanced=True, + ) + size_limit: int = SchemaField( + description="Maximum size in bytes per chunk to yield (0 for no limit)", + default=0, + advanced=True, + ) + row_limit: int = SchemaField( + description="Maximum number of rows to process (0 for no limit, requires delimiter)", + default=0, + advanced=True, + ) + skip_size: int = SchemaField( + description="Number of characters to skip from the beginning of the file", + default=0, + advanced=True, + ) + skip_rows: int = SchemaField( + description="Number of rows to skip from the beginning (requires delimiter)", + default=0, + advanced=True, + ) + + class Output(BlockSchema): + content: str = SchemaField( + description="File content, yielded as individual chunks when delimiter or size limits are applied" + ) + + def __init__(self): + super().__init__( + id="3735a31f-7e18-4aca-9e90-08a7120674bc", + input_schema=FileReadBlock.Input, + output_schema=FileReadBlock.Output, + description="Reads a file and returns its content as a string, with optional chunking by delimiter and size limits", + categories={BlockCategory.TEXT, BlockCategory.DATA}, + test_input={ + "file_input": "data:text/plain;base64,SGVsbG8gV29ybGQ=", + }, + test_output=[ + ("content", "Hello World"), + ], + ) + + async def run( + self, input_data: Input, *, graph_exec_id: str, user_id: str, **_kwargs + ) -> BlockOutput: + # Store the media file properly (handles URLs, data URIs, etc.) + stored_file_path = await store_media_file( + user_id=user_id, + graph_exec_id=graph_exec_id, + file=input_data.file_input, + return_content=False, + ) + + # Get full file path + file_path = get_exec_file_path(graph_exec_id, stored_file_path) + + if not Path(file_path).exists(): + raise ValueError(f"File does not exist: {file_path}") + + # Read file content + try: + with open(file_path, "r", encoding="utf-8") as file: + content = file.read() + except UnicodeDecodeError: + # Try with different encodings + try: + with open(file_path, "r", encoding="latin-1") as file: + content = file.read() + except Exception as e: + raise ValueError(f"Unable to read file: {e}") + + # Apply skip_size (character-level skip) + if input_data.skip_size > 0: + content = content[input_data.skip_size :] + + # Split content into items (by delimiter or treat as single item) + items = ( + content.split(input_data.delimiter) if input_data.delimiter else [content] + ) + + # Apply skip_rows (item-level skip) + if input_data.skip_rows > 0: + items = items[input_data.skip_rows :] + + # Apply row_limit (item-level limit) + if input_data.row_limit > 0: + items = items[: input_data.row_limit] + + # Process each item and create chunks + def create_chunks(text, size_limit): + """Create chunks from text based on size_limit""" + if size_limit <= 0: + return [text] if text else [] + + chunks = [] + for i in range(0, len(text), size_limit): + chunk = text[i : i + size_limit] + if chunk: # Only add non-empty chunks + chunks.append(chunk) + return chunks + + # Process items and yield as content chunks + if items: + full_content = ( + input_data.delimiter.join(items) + if input_data.delimiter + else "".join(items) + ) + + # Create chunks of the full content based on size_limit + content_chunks = create_chunks(full_content, input_data.size_limit) + for chunk in content_chunks: + yield "content", chunk + else: + yield "content", "" diff --git a/autogpt_platform/backend/backend/blocks/text_to_speech_block.py b/autogpt_platform/backend/backend/blocks/text_to_speech_block.py index dc9e0bf26e68..f0b7c107de1c 100644 --- a/autogpt_platform/backend/backend/blocks/text_to_speech_block.py +++ b/autogpt_platform/backend/backend/blocks/text_to_speech_block.py @@ -10,7 +10,7 @@ SchemaField, ) from backend.integrations.providers import ProviderName -from backend.util.request import requests +from backend.util.request import Requests TEST_CREDENTIALS = APIKeyCredentials( id="01234567-89ab-cdef-0123-456789abcdef", @@ -53,7 +53,7 @@ def __init__(self): super().__init__( id="4ff1ff6d-cc40-4caa-ae69-011daa20c378", description="Converts text to speech using the Unreal Speech API", - categories={BlockCategory.AI, BlockCategory.TEXT}, + categories={BlockCategory.AI, BlockCategory.TEXT, BlockCategory.MULTIMEDIA}, input_schema=UnrealTextToSpeechBlock.Input, output_schema=UnrealTextToSpeechBlock.Output, test_input={ @@ -71,7 +71,7 @@ def __init__(self): ) @staticmethod - def call_unreal_speech_api( + async def call_unreal_speech_api( api_key: SecretStr, text: str, voice_id: str ) -> dict[str, Any]: url = "https://api.v7.unrealspeech.com/speech" @@ -88,13 +88,13 @@ def call_unreal_speech_api( "TimestampType": "sentence", } - response = requests.post(url, headers=headers, json=data) + response = await Requests().post(url, headers=headers, json=data) return response.json() - def run( + async def run( self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs ) -> BlockOutput: - api_response = self.call_unreal_speech_api( + api_response = await self.call_unreal_speech_api( credentials.api_key, input_data.text, input_data.voice_id, diff --git a/autogpt_platform/backend/backend/blocks/time_blocks.py b/autogpt_platform/backend/backend/blocks/time_blocks.py index 4b060aea5cfc..df5c34af5c80 100644 --- a/autogpt_platform/backend/backend/blocks/time_blocks.py +++ b/autogpt_platform/backend/backend/blocks/time_blocks.py @@ -1,18 +1,144 @@ +import asyncio +import logging import time from datetime import datetime, timedelta -from typing import Any, Union +from typing import Any, Literal, Union +from zoneinfo import ZoneInfo + +from pydantic import BaseModel from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.execution import UserContext from backend.data.model import SchemaField +# Shared timezone literal type for all time/date blocks +TimezoneLiteral = Literal[ + "UTC", # UTC±00:00 + "Pacific/Honolulu", # UTC-10:00 + "America/Anchorage", # UTC-09:00 (Alaska) + "America/Los_Angeles", # UTC-08:00 (Pacific) + "America/Denver", # UTC-07:00 (Mountain) + "America/Chicago", # UTC-06:00 (Central) + "America/New_York", # UTC-05:00 (Eastern) + "America/Caracas", # UTC-04:00 + "America/Sao_Paulo", # UTC-03:00 + "America/St_Johns", # UTC-02:30 (Newfoundland) + "Atlantic/South_Georgia", # UTC-02:00 + "Atlantic/Azores", # UTC-01:00 + "Europe/London", # UTC+00:00 (GMT/BST) + "Europe/Paris", # UTC+01:00 (CET) + "Europe/Athens", # UTC+02:00 (EET) + "Europe/Moscow", # UTC+03:00 + "Asia/Tehran", # UTC+03:30 (Iran) + "Asia/Dubai", # UTC+04:00 + "Asia/Kabul", # UTC+04:30 (Afghanistan) + "Asia/Karachi", # UTC+05:00 (Pakistan) + "Asia/Kolkata", # UTC+05:30 (India) + "Asia/Kathmandu", # UTC+05:45 (Nepal) + "Asia/Dhaka", # UTC+06:00 (Bangladesh) + "Asia/Yangon", # UTC+06:30 (Myanmar) + "Asia/Bangkok", # UTC+07:00 + "Asia/Shanghai", # UTC+08:00 (China) + "Australia/Eucla", # UTC+08:45 + "Asia/Tokyo", # UTC+09:00 (Japan) + "Australia/Adelaide", # UTC+09:30 + "Australia/Sydney", # UTC+10:00 + "Australia/Lord_Howe", # UTC+10:30 + "Pacific/Noumea", # UTC+11:00 + "Pacific/Auckland", # UTC+12:00 (New Zealand) + "Pacific/Chatham", # UTC+12:45 + "Pacific/Tongatapu", # UTC+13:00 + "Pacific/Kiritimati", # UTC+14:00 + "Etc/GMT-12", # UTC+12:00 + "Etc/GMT+12", # UTC-12:00 +] + +logger = logging.getLogger(__name__) + + +def _get_timezone( + format_type: Any, # Any format type with timezone and use_user_timezone attributes + user_timezone: str | None, +) -> ZoneInfo: + """ + Determine which timezone to use based on format settings and user context. + + Args: + format_type: The format configuration containing timezone settings + user_timezone: The user's timezone from context + + Returns: + ZoneInfo object for the determined timezone + """ + if format_type.use_user_timezone and user_timezone: + tz = ZoneInfo(user_timezone) + logger.debug(f"Using user timezone: {user_timezone}") + else: + tz = ZoneInfo(format_type.timezone) + logger.debug(f"Using specified timezone: {format_type.timezone}") + return tz + + +def _format_datetime_iso8601(dt: datetime, include_microseconds: bool = False) -> str: + """ + Format a datetime object to ISO8601 string. + + Args: + dt: The datetime object to format + include_microseconds: Whether to include microseconds in the output + + Returns: + ISO8601 formatted string + """ + if include_microseconds: + return dt.isoformat() + else: + return dt.isoformat(timespec="seconds") + + +# BACKWARDS COMPATIBILITY NOTE: +# The timezone field is kept at the format level (not block level) for backwards compatibility. +# Existing graphs have timezone saved within format_type, moving it would break them. +# +# The use_user_timezone flag was added to allow using the user's profile timezone. +# Default is False to maintain backwards compatibility - existing graphs will continue +# using their specified timezone. +# +# KNOWN ISSUE: If a user switches between format types (strftime <-> iso8601), +# the timezone setting doesn't carry over. This is a UX issue but fixing it would +# require either: +# 1. Moving timezone to block level (breaking change, needs migration) +# 2. Complex state management to sync timezone across format types +# +# Future migration path: When we do a major version bump, consider moving timezone +# to the block Input level for better UX. + + +class TimeStrftimeFormat(BaseModel): + discriminator: Literal["strftime"] + format: str = "%H:%M:%S" + timezone: TimezoneLiteral = "UTC" + # When True, overrides timezone with user's profile timezone + use_user_timezone: bool = False + + +class TimeISO8601Format(BaseModel): + discriminator: Literal["iso8601"] + timezone: TimezoneLiteral = "UTC" + # When True, overrides timezone with user's profile timezone + use_user_timezone: bool = False + include_microseconds: bool = False + class GetCurrentTimeBlock(Block): class Input(BlockSchema): trigger: str = SchemaField( description="Trigger any data to output the current time" ) - format: str = SchemaField( - description="Format of the time to output", default="%H:%M:%S" + format_type: Union[TimeStrftimeFormat, TimeISO8601Format] = SchemaField( + discriminator="discriminator", + description="Format type for time output (strftime with custom format or ISO 8601)", + default=TimeStrftimeFormat(discriminator="strftime"), ) class Output(BlockSchema): @@ -29,19 +155,71 @@ def __init__(self): output_schema=GetCurrentTimeBlock.Output, test_input=[ {"trigger": "Hello"}, - {"trigger": "Hello", "format": "%H:%M"}, + { + "trigger": "Hello", + "format_type": { + "discriminator": "strftime", + "format": "%H:%M", + }, + }, + { + "trigger": "Hello", + "format_type": { + "discriminator": "iso8601", + "timezone": "UTC", + "include_microseconds": False, + }, + }, ], test_output=[ ("time", lambda _: time.strftime("%H:%M:%S")), ("time", lambda _: time.strftime("%H:%M")), + ( + "time", + lambda t: "T" in t and ("+" in t or "Z" in t), + ), # Check for ISO format with timezone ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: - current_time = time.strftime(input_data.format) + async def run( + self, input_data: Input, *, user_context: UserContext, **kwargs + ) -> BlockOutput: + # Extract timezone from user_context (always present) + effective_timezone = user_context.timezone + + # Get the appropriate timezone + tz = _get_timezone(input_data.format_type, effective_timezone) + dt = datetime.now(tz=tz) + + if isinstance(input_data.format_type, TimeISO8601Format): + # Get the full ISO format and extract just the time portion with timezone + full_iso = _format_datetime_iso8601( + dt, input_data.format_type.include_microseconds + ) + # Extract time portion (everything after 'T') + current_time = full_iso.split("T")[1] if "T" in full_iso else full_iso + current_time = f"T{current_time}" # Add T prefix for ISO 8601 time format + else: # TimeStrftimeFormat + current_time = dt.strftime(input_data.format_type.format) + yield "time", current_time +class DateStrftimeFormat(BaseModel): + discriminator: Literal["strftime"] + format: str = "%Y-%m-%d" + timezone: TimezoneLiteral = "UTC" + # When True, overrides timezone with user's profile timezone + use_user_timezone: bool = False + + +class DateISO8601Format(BaseModel): + discriminator: Literal["iso8601"] + timezone: TimezoneLiteral = "UTC" + # When True, overrides timezone with user's profile timezone + use_user_timezone: bool = False + + class GetCurrentDateBlock(Block): class Input(BlockSchema): trigger: str = SchemaField( @@ -52,8 +230,10 @@ class Input(BlockSchema): description="Offset in days from the current date", default=0, ) - format: str = SchemaField( - description="Format of the date to output", default="%Y-%m-%d" + format_type: Union[DateStrftimeFormat, DateISO8601Format] = SchemaField( + discriminator="discriminator", + description="Format type for date output (strftime with custom format or ISO 8601)", + default=DateStrftimeFormat(discriminator="strftime"), ) class Output(BlockSchema): @@ -70,7 +250,22 @@ def __init__(self): output_schema=GetCurrentDateBlock.Output, test_input=[ {"trigger": "Hello", "offset": "7"}, - {"trigger": "Hello", "offset": "7", "format": "%m/%d/%Y"}, + { + "trigger": "Hello", + "offset": "7", + "format_type": { + "discriminator": "strftime", + "format": "%m/%d/%Y", + }, + }, + { + "trigger": "Hello", + "offset": "0", + "format_type": { + "discriminator": "iso8601", + "timezone": "UTC", + }, + }, ], test_output=[ ( @@ -84,16 +279,52 @@ def __init__(self): < timedelta(days=8), # 7 days difference + 1 day error margin. ), + ( + "date", + lambda t: len(t) == 10 + and t[4] == "-" + and t[7] == "-", # ISO date format YYYY-MM-DD + ), ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + # Extract timezone from user_context (required keyword argument) + user_context: UserContext = kwargs["user_context"] + effective_timezone = user_context.timezone + try: offset = int(input_data.offset) except ValueError: offset = 0 - current_date = datetime.now() - timedelta(days=offset) - yield "date", current_date.strftime(input_data.format) + + # Get the appropriate timezone + tz = _get_timezone(input_data.format_type, effective_timezone) + current_date = datetime.now(tz=tz) - timedelta(days=offset) + + if isinstance(input_data.format_type, DateISO8601Format): + # ISO 8601 date format is YYYY-MM-DD + date_str = current_date.date().isoformat() + else: # DateStrftimeFormat + date_str = current_date.strftime(input_data.format_type.format) + + yield "date", date_str + + +class StrftimeFormat(BaseModel): + discriminator: Literal["strftime"] + format: str = "%Y-%m-%d %H:%M:%S" + timezone: TimezoneLiteral = "UTC" + # When True, overrides timezone with user's profile timezone + use_user_timezone: bool = False + + +class ISO8601Format(BaseModel): + discriminator: Literal["iso8601"] + timezone: TimezoneLiteral = "UTC" + # When True, overrides timezone with user's profile timezone + use_user_timezone: bool = False + include_microseconds: bool = False class GetCurrentDateAndTimeBlock(Block): @@ -101,9 +332,10 @@ class Input(BlockSchema): trigger: str = SchemaField( description="Trigger any data to output the current date and time" ) - format: str = SchemaField( - description="Format of the date and time to output", - default="%Y-%m-%d %H:%M:%S", + format_type: Union[StrftimeFormat, ISO8601Format] = SchemaField( + discriminator="discriminator", + description="Format type for date and time output (strftime with custom format or ISO 8601/RFC 3339)", + default=StrftimeFormat(discriminator="strftime"), ) class Output(BlockSchema): @@ -120,20 +352,65 @@ def __init__(self): output_schema=GetCurrentDateAndTimeBlock.Output, test_input=[ {"trigger": "Hello"}, + { + "trigger": "Hello", + "format_type": { + "discriminator": "strftime", + "format": "%Y/%m/%d", + }, + }, + { + "trigger": "Hello", + "format_type": { + "discriminator": "iso8601", + "timezone": "UTC", + "include_microseconds": False, + }, + }, ], test_output=[ ( "date_time", lambda t: abs( - datetime.now() - datetime.strptime(t, "%Y-%m-%d %H:%M:%S") + datetime.now(tz=ZoneInfo("UTC")) + - datetime.strptime(t + "+00:00", "%Y-%m-%d %H:%M:%S%z") ) < timedelta(seconds=10), # 10 seconds error margin. ), + ( + "date_time", + lambda t: abs( + datetime.now().date() - datetime.strptime(t, "%Y/%m/%d").date() + ) + < timedelta(days=1), # Date format only, no time component + ), + ( + "date_time", + lambda t: abs( + datetime.now(tz=ZoneInfo("UTC")) - datetime.fromisoformat(t) + ) + < timedelta(seconds=10), # 10 seconds error margin for ISO format. + ), ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: - current_date_time = time.strftime(input_data.format) + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + # Extract timezone from user_context (required keyword argument) + user_context: UserContext = kwargs["user_context"] + effective_timezone = user_context.timezone + + # Get the appropriate timezone + tz = _get_timezone(input_data.format_type, effective_timezone) + dt = datetime.now(tz=tz) + + if isinstance(input_data.format_type, ISO8601Format): + # ISO 8601 format with specified timezone (also RFC3339-compliant) + current_date_time = _format_datetime_iso8601( + dt, input_data.format_type.include_microseconds + ) + else: # StrftimeFormat + current_date_time = dt.strftime(input_data.format_type.format) + yield "date_time", current_date_time @@ -156,6 +433,10 @@ class Input(BlockSchema): days: Union[int, str] = SchemaField( advanced=False, description="Duration in days", default=0 ) + repeat: int = SchemaField( + description="Number of times to repeat the timer", + default=1, + ) class Output(BlockSchema): output_message: Any = SchemaField( @@ -179,7 +460,7 @@ def __init__(self): ], ) - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: seconds = int(input_data.seconds) minutes = int(input_data.minutes) hours = int(input_data.hours) @@ -187,5 +468,7 @@ def run(self, input_data: Input, **kwargs) -> BlockOutput: total_seconds = seconds + minutes * 60 + hours * 3600 + days * 86400 - time.sleep(total_seconds) - yield "output_message", input_data.input_message + for _ in range(input_data.repeat): + if total_seconds > 0: + await asyncio.sleep(total_seconds) + yield "output_message", input_data.input_message diff --git a/autogpt_platform/backend/backend/blocks/todoist/_auth.py b/autogpt_platform/backend/backend/blocks/todoist/_auth.py new file mode 100644 index 000000000000..0b05c63bbf09 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/todoist/_auth.py @@ -0,0 +1,61 @@ +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import ( + CredentialsField, + CredentialsMetaInput, + OAuth2Credentials, + ProviderName, +) +from backend.integrations.oauth.todoist import TodoistOAuthHandler +from backend.util.settings import Secrets + +secrets = Secrets() +TODOIST_OAUTH_IS_CONFIGURED = bool( + secrets.todoist_client_id and secrets.todoist_client_secret +) + +TodoistCredentials = OAuth2Credentials +TodoistCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.TODOIST], Literal["oauth2"] +] + + +def TodoistCredentialsField(scopes: list[str]) -> TodoistCredentialsInput: + """ + Creates a Todoist credentials input on a block. + + Params: + scopes: The authorization scopes needed for the block to work. + """ + return CredentialsField( + required_scopes=set(TodoistOAuthHandler.DEFAULT_SCOPES + scopes), + description="The Todoist integration requires OAuth2 authentication.", + ) + + +TEST_CREDENTIALS = OAuth2Credentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="todoist", + access_token=SecretStr("mock-todoist-access-token"), + refresh_token=None, + access_token_expires_at=None, + scopes=[ + "task:add", + "data:read", + "data:read_write", + "data:delete", + "project:delete", + ], + title="Mock Todoist OAuth2 Credentials", + username="mock-todoist-username", + refresh_token_expires_at=None, +) + +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} diff --git a/autogpt_platform/backend/backend/blocks/todoist/_types.py b/autogpt_platform/backend/backend/blocks/todoist/_types.py new file mode 100644 index 000000000000..ecbdbd4baef9 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/todoist/_types.py @@ -0,0 +1,24 @@ +from enum import Enum + + +class Colors(Enum): + berry_red = "berry_red" + red = "red" + orange = "orange" + yellow = "yellow" + olive_green = "olive_green" + lime_green = "lime_green" + green = "green" + mint_green = "mint_green" + teal = "teal" + sky_blue = "sky_blue" + light_blue = "light_blue" + blue = "blue" + grape = "grape" + violet = "violet" + lavender = "lavender" + magenta = "magenta" + salmon = "salmon" + charcoal = "charcoal" + grey = "grey" + taupe = "taupe" diff --git a/autogpt_platform/backend/backend/blocks/todoist/comments.py b/autogpt_platform/backend/backend/blocks/todoist/comments.py new file mode 100644 index 000000000000..703afb696fb5 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/todoist/comments.py @@ -0,0 +1,445 @@ +from typing import Literal, Union + +from pydantic import BaseModel +from todoist_api_python.api import TodoistAPI +from typing_extensions import Optional + +from backend.blocks.todoist._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TODOIST_OAUTH_IS_CONFIGURED, + TodoistCredentials, + TodoistCredentialsField, + TodoistCredentialsInput, +) +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TaskId(BaseModel): + discriminator: Literal["task"] + task_id: str + + +class ProjectId(BaseModel): + discriminator: Literal["project"] + project_id: str + + +class TodoistCreateCommentBlock(Block): + """Creates a new comment on a Todoist task or project""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + content: str = SchemaField(description="Comment content") + id_type: Union[TaskId, ProjectId] = SchemaField( + discriminator="discriminator", + description="Specify either task_id or project_id to comment on", + default=TaskId(discriminator="task", task_id=""), + advanced=False, + ) + attachment: Optional[dict] = SchemaField( + description="Optional file attachment", default=None + ) + + class Output(BlockSchema): + id: str = SchemaField(description="ID of created comment") + content: str = SchemaField(description="Comment content") + posted_at: str = SchemaField(description="Comment timestamp") + task_id: Optional[str] = SchemaField( + description="Associated task ID", default=None + ) + project_id: Optional[str] = SchemaField( + description="Associated project ID", default=None + ) + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="1bba7e54-2310-4a31-8e6f-54d5f9ab7459", + description="Creates a new comment on a Todoist task or project", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistCreateCommentBlock.Input, + output_schema=TodoistCreateCommentBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "content": "Test comment", + "id_type": {"discriminator": "task", "task_id": "2995104339"}, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", "2992679862"), + ("content", "Test comment"), + ("posted_at", "2016-09-22T07:00:00.000000Z"), + ("task_id", "2995104339"), + ("project_id", None), + ], + test_mock={ + "create_comment": lambda content, credentials, task_id=None, project_id=None, attachment=None: { + "id": "2992679862", + "content": "Test comment", + "posted_at": "2016-09-22T07:00:00.000000Z", + "task_id": "2995104339", + "project_id": None, + } + }, + ) + + @staticmethod + def create_comment( + credentials: TodoistCredentials, + content: str, + task_id: Optional[str] = None, + project_id: Optional[str] = None, + attachment: Optional[dict] = None, + ): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + comment = api.add_comment( + content=content, + task_id=task_id, + project_id=project_id, + attachment=attachment, + ) + return comment.__dict__ + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + task_id = None + project_id = None + + if isinstance(input_data.id_type, TaskId): + task_id = input_data.id_type.task_id + else: + project_id = input_data.id_type.project_id + + comment_data = self.create_comment( + credentials, + input_data.content, + task_id=task_id, + project_id=project_id, + attachment=input_data.attachment, + ) + + if comment_data: + yield "id", comment_data["id"] + yield "content", comment_data["content"] + yield "posted_at", comment_data["posted_at"] + yield "task_id", comment_data["task_id"] + yield "project_id", comment_data["project_id"] + + except Exception as e: + yield "error", str(e) + + +class TodoistGetCommentsBlock(Block): + """Get all comments for a Todoist task or project""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + id_type: Union[TaskId, ProjectId] = SchemaField( + discriminator="discriminator", + description="Specify either task_id or project_id to get comments for", + default=TaskId(discriminator="task", task_id=""), + advanced=False, + ) + + class Output(BlockSchema): + comments: list = SchemaField(description="List of comments") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="9972d8ae-ddf2-11ef-a9b8-32d3674e8b7e", + description="Get all comments for a Todoist task or project", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistGetCommentsBlock.Input, + output_schema=TodoistGetCommentsBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "id_type": {"discriminator": "task", "task_id": "2995104339"}, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "comments", + [ + { + "id": "2992679862", + "content": "Test comment", + "posted_at": "2016-09-22T07:00:00.000000Z", + "task_id": "2995104339", + "project_id": None, + "attachment": None, + } + ], + ) + ], + test_mock={ + "get_comments": lambda credentials, task_id=None, project_id=None: [ + { + "id": "2992679862", + "content": "Test comment", + "posted_at": "2016-09-22T07:00:00.000000Z", + "task_id": "2995104339", + "project_id": None, + "attachment": None, + } + ] + }, + ) + + @staticmethod + def get_comments( + credentials: TodoistCredentials, + task_id: Optional[str] = None, + project_id: Optional[str] = None, + ): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + comments = api.get_comments(task_id=task_id, project_id=project_id) + return [comment.__dict__ for comment in comments] + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + task_id = None + project_id = None + + if isinstance(input_data.id_type, TaskId): + task_id = input_data.id_type.task_id + else: + project_id = input_data.id_type.project_id + + comments = self.get_comments( + credentials, task_id=task_id, project_id=project_id + ) + + yield "comments", comments + + except Exception as e: + yield "error", str(e) + + +class TodoistGetCommentBlock(Block): + """Get a single comment from Todoist using comment ID""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + comment_id: str = SchemaField(description="Comment ID to retrieve") + + class Output(BlockSchema): + content: str = SchemaField(description="Comment content") + id: str = SchemaField(description="Comment ID") + posted_at: str = SchemaField(description="Comment timestamp") + project_id: Optional[str] = SchemaField( + description="Associated project ID", default=None + ) + task_id: Optional[str] = SchemaField( + description="Associated task ID", default=None + ) + attachment: Optional[dict] = SchemaField( + description="Optional file attachment", default=None + ) + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="a809d264-ddf2-11ef-9764-32d3674e8b7e", + description="Get a single comment from Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistGetCommentBlock.Input, + output_schema=TodoistGetCommentBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "comment_id": "2992679862", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("content", "Test comment"), + ("id", "2992679862"), + ("posted_at", "2016-09-22T07:00:00.000000Z"), + ("project_id", None), + ("task_id", "2995104339"), + ("attachment", None), + ], + test_mock={ + "get_comment": lambda credentials, comment_id: { + "content": "Test comment", + "id": "2992679862", + "posted_at": "2016-09-22T07:00:00.000000Z", + "project_id": None, + "task_id": "2995104339", + "attachment": None, + } + }, + ) + + @staticmethod + def get_comment(credentials: TodoistCredentials, comment_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + comment = api.get_comment(comment_id=comment_id) + return comment.__dict__ + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + comment_data = self.get_comment( + credentials, comment_id=input_data.comment_id + ) + + if comment_data: + yield "content", comment_data["content"] + yield "id", comment_data["id"] + yield "posted_at", comment_data["posted_at"] + yield "project_id", comment_data["project_id"] + yield "task_id", comment_data["task_id"] + yield "attachment", comment_data["attachment"] + + except Exception as e: + yield "error", str(e) + + +class TodoistUpdateCommentBlock(Block): + """Updates a Todoist comment""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + comment_id: str = SchemaField(description="Comment ID to update") + content: str = SchemaField(description="New content for the comment") + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the update was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="b773c520-ddf2-11ef-9f34-32d3674e8b7e", + description="Updates a Todoist comment", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistUpdateCommentBlock.Input, + output_schema=TodoistUpdateCommentBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "comment_id": "2992679862", + "content": "Need one bottle of milk", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"update_comment": lambda credentials, comment_id, content: True}, + ) + + @staticmethod + def update_comment(credentials: TodoistCredentials, comment_id: str, content: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + api.update_comment(comment_id=comment_id, content=content) + return True + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.update_comment( + credentials, + comment_id=input_data.comment_id, + content=input_data.content, + ) + + yield "success", success + + except Exception as e: + yield "error", str(e) + + +class TodoistDeleteCommentBlock(Block): + """Deletes a Todoist comment""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + comment_id: str = SchemaField(description="Comment ID to delete") + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the deletion was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="bda4c020-ddf2-11ef-b114-32d3674e8b7e", + description="Deletes a Todoist comment", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistDeleteCommentBlock.Input, + output_schema=TodoistDeleteCommentBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "comment_id": "2992679862", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"delete_comment": lambda credentials, comment_id: True}, + ) + + @staticmethod + def delete_comment(credentials: TodoistCredentials, comment_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + success = api.delete_comment(comment_id=comment_id) + return success + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.delete_comment(credentials, comment_id=input_data.comment_id) + + yield "success", success + + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/todoist/labels.py b/autogpt_platform/backend/backend/blocks/todoist/labels.py new file mode 100644 index 000000000000..4700ebb6c082 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/todoist/labels.py @@ -0,0 +1,566 @@ +from todoist_api_python.api import TodoistAPI +from typing_extensions import Optional + +from backend.blocks.todoist._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TODOIST_OAUTH_IS_CONFIGURED, + TodoistCredentials, + TodoistCredentialsField, + TodoistCredentialsInput, +) +from backend.blocks.todoist._types import Colors +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TodoistCreateLabelBlock(Block): + """Creates a new label in Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + name: str = SchemaField(description="Name of the label") + order: Optional[int] = SchemaField(description="Label order", default=None) + color: Optional[Colors] = SchemaField( + description="The color of the label icon", default=Colors.charcoal + ) + is_favorite: bool = SchemaField( + description="Whether the label is a favorite", default=False + ) + + class Output(BlockSchema): + id: str = SchemaField(description="ID of the created label") + name: str = SchemaField(description="Name of the label") + color: str = SchemaField(description="Color of the label") + order: int = SchemaField(description="Label order") + is_favorite: bool = SchemaField(description="Favorite status") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="7288a968-de14-11ef-8997-32d3674e8b7e", + description="Creates a new label in Todoist, It will not work if same name already exists", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistCreateLabelBlock.Input, + output_schema=TodoistCreateLabelBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "name": "Test Label", + "color": Colors.charcoal.value, + "order": 1, + "is_favorite": False, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", "2156154810"), + ("name", "Test Label"), + ("color", "charcoal"), + ("order", 1), + ("is_favorite", False), + ], + test_mock={ + "create_label": lambda *args, **kwargs: { + "id": "2156154810", + "name": "Test Label", + "color": "charcoal", + "order": 1, + "is_favorite": False, + } + }, + ) + + @staticmethod + def create_label(credentials: TodoistCredentials, name: str, **kwargs): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + label = api.add_label(name=name, **kwargs) + return label.__dict__ + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + label_args = { + "order": input_data.order, + "color": ( + input_data.color.value if input_data.color is not None else None + ), + "is_favorite": input_data.is_favorite, + } + + label_data = self.create_label( + credentials, + input_data.name, + **{k: v for k, v in label_args.items() if v is not None}, + ) + + if label_data: + yield "id", label_data["id"] + yield "name", label_data["name"] + yield "color", label_data["color"] + yield "order", label_data["order"] + yield "is_favorite", label_data["is_favorite"] + + except Exception as e: + yield "error", str(e) + + +class TodoistListLabelsBlock(Block): + """Gets all personal labels from Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + + class Output(BlockSchema): + labels: list = SchemaField(description="List of complete label data") + label_ids: list = SchemaField(description="List of label IDs") + label_names: list = SchemaField(description="List of label names") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="776dd750-de14-11ef-b927-32d3674e8b7e", + description="Gets all personal labels from Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistListLabelsBlock.Input, + output_schema=TodoistListLabelsBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={"credentials": TEST_CREDENTIALS_INPUT}, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ( + "labels", + [ + { + "id": "2156154810", + "name": "Test Label", + "color": "charcoal", + "order": 1, + "is_favorite": False, + } + ], + ), + ("label_ids", ["2156154810"]), + ("label_names", ["Test Label"]), + ], + test_mock={ + "get_labels": lambda *args, **kwargs: [ + { + "id": "2156154810", + "name": "Test Label", + "color": "charcoal", + "order": 1, + "is_favorite": False, + } + ] + }, + ) + + @staticmethod + def get_labels(credentials: TodoistCredentials): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + labels = api.get_labels() + return [label.__dict__ for label in labels] + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + labels = self.get_labels(credentials) + yield "labels", labels + yield "label_ids", [label["id"] for label in labels] + yield "label_names", [label["name"] for label in labels] + + except Exception as e: + yield "error", str(e) + + +class TodoistGetLabelBlock(Block): + """Gets a personal label from Todoist by ID""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + label_id: str = SchemaField(description="ID of the label to retrieve") + + class Output(BlockSchema): + id: str = SchemaField(description="ID of the label") + name: str = SchemaField(description="Name of the label") + color: str = SchemaField(description="Color of the label") + order: int = SchemaField(description="Label order") + is_favorite: bool = SchemaField(description="Favorite status") + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="7f236514-de14-11ef-bd7a-32d3674e8b7e", + description="Gets a personal label from Todoist by ID", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistGetLabelBlock.Input, + output_schema=TodoistGetLabelBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "label_id": "2156154810", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", "2156154810"), + ("name", "Test Label"), + ("color", "charcoal"), + ("order", 1), + ("is_favorite", False), + ], + test_mock={ + "get_label": lambda *args, **kwargs: { + "id": "2156154810", + "name": "Test Label", + "color": "charcoal", + "order": 1, + "is_favorite": False, + } + }, + ) + + @staticmethod + def get_label(credentials: TodoistCredentials, label_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + label = api.get_label(label_id=label_id) + return label.__dict__ + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + label_data = self.get_label(credentials, input_data.label_id) + + if label_data: + yield "id", label_data["id"] + yield "name", label_data["name"] + yield "color", label_data["color"] + yield "order", label_data["order"] + yield "is_favorite", label_data["is_favorite"] + + except Exception as e: + yield "error", str(e) + + +class TodoistUpdateLabelBlock(Block): + """Updates a personal label in Todoist using ID""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + label_id: str = SchemaField(description="ID of the label to update") + name: Optional[str] = SchemaField( + description="New name of the label", default=None + ) + order: Optional[int] = SchemaField(description="Label order", default=None) + color: Optional[Colors] = SchemaField( + description="The color of the label icon", default=None + ) + is_favorite: bool = SchemaField( + description="Whether the label is a favorite (true/false)", default=False + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the update was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="8755614c-de14-11ef-9b56-32d3674e8b7e", + description="Updates a personal label in Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistUpdateLabelBlock.Input, + output_schema=TodoistUpdateLabelBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "label_id": "2156154810", + "name": "Updated Label", + "color": Colors.charcoal.value, + "order": 2, + "is_favorite": True, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"update_label": lambda *args, **kwargs: True}, + ) + + @staticmethod + def update_label(credentials: TodoistCredentials, label_id: str, **kwargs): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + api.update_label(label_id=label_id, **kwargs) + return True + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + label_args = {} + if input_data.name is not None: + label_args["name"] = input_data.name + if input_data.order is not None: + label_args["order"] = input_data.order + if input_data.color is not None: + label_args["color"] = input_data.color.value + if input_data.is_favorite is not None: + label_args["is_favorite"] = input_data.is_favorite + + success = self.update_label( + credentials, + input_data.label_id, + **{k: v for k, v in label_args.items() if v is not None}, + ) + + yield "success", success + + except Exception as e: + yield "error", str(e) + + +class TodoistDeleteLabelBlock(Block): + """Deletes a personal label in Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + label_id: str = SchemaField(description="ID of the label to delete") + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the deletion was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="901b8f86-de14-11ef-98b8-32d3674e8b7e", + description="Deletes a personal label in Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistDeleteLabelBlock.Input, + output_schema=TodoistDeleteLabelBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "label_id": "2156154810", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"delete_label": lambda *args, **kwargs: True}, + ) + + @staticmethod + def delete_label(credentials: TodoistCredentials, label_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + success = api.delete_label(label_id=label_id) + return success + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.delete_label(credentials, input_data.label_id) + yield "success", success + + except Exception as e: + yield "error", str(e) + + +class TodoistGetSharedLabelsBlock(Block): + """Gets all shared labels from Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + + class Output(BlockSchema): + labels: list = SchemaField(description="List of shared label names") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="55fba510-de15-11ef-aed2-32d3674e8b7e", + description="Gets all shared labels from Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistGetSharedLabelsBlock.Input, + output_schema=TodoistGetSharedLabelsBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={"credentials": TEST_CREDENTIALS_INPUT}, + test_credentials=TEST_CREDENTIALS, + test_output=[("labels", ["Label1", "Label2", "Label3"])], + test_mock={ + "get_shared_labels": lambda *args, **kwargs: [ + "Label1", + "Label2", + "Label3", + ] + }, + ) + + @staticmethod + def get_shared_labels(credentials: TodoistCredentials): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + labels = api.get_shared_labels() + return labels + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + labels = self.get_shared_labels(credentials) + yield "labels", labels + + except Exception as e: + yield "error", str(e) + + +class TodoistRenameSharedLabelsBlock(Block): + """Renames all instances of a shared label""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + name: str = SchemaField(description="The name of the existing label to rename") + new_name: str = SchemaField(description="The new name for the label") + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the rename was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="9d63ad9a-de14-11ef-ab3f-32d3674e8b7e", + description="Renames all instances of a shared label", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistRenameSharedLabelsBlock.Input, + output_schema=TodoistRenameSharedLabelsBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "name": "OldLabel", + "new_name": "NewLabel", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"rename_shared_labels": lambda *args, **kwargs: True}, + ) + + @staticmethod + def rename_shared_labels(credentials: TodoistCredentials, name: str, new_name: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + success = api.rename_shared_label(name=name, new_name=new_name) + return success + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.rename_shared_labels( + credentials, input_data.name, input_data.new_name + ) + yield "success", success + + except Exception as e: + yield "error", str(e) + + +class TodoistRemoveSharedLabelsBlock(Block): + """Removes all instances of a shared label""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + name: str = SchemaField(description="The name of the label to remove") + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the removal was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="a6c5cbde-de14-11ef-8863-32d3674e8b7e", + description="Removes all instances of a shared label", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistRemoveSharedLabelsBlock.Input, + output_schema=TodoistRemoveSharedLabelsBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={"credentials": TEST_CREDENTIALS_INPUT, "name": "LabelToRemove"}, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"remove_shared_label": lambda *args, **kwargs: True}, + ) + + @staticmethod + def remove_shared_label(credentials: TodoistCredentials, name: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + success = api.remove_shared_label(name=name) + return success + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.remove_shared_label(credentials, input_data.name) + yield "success", success + + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/todoist/projects.py b/autogpt_platform/backend/backend/blocks/todoist/projects.py new file mode 100644 index 000000000000..33ad7950faee --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/todoist/projects.py @@ -0,0 +1,573 @@ +from todoist_api_python.api import TodoistAPI +from typing_extensions import Optional + +from backend.blocks.todoist._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TODOIST_OAUTH_IS_CONFIGURED, + TodoistCredentials, + TodoistCredentialsField, + TodoistCredentialsInput, +) +from backend.blocks.todoist._types import Colors +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TodoistListProjectsBlock(Block): + """Gets all projects for a Todoist user""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + + class Output(BlockSchema): + names_list: list[str] = SchemaField(description="List of project names") + ids_list: list[str] = SchemaField(description="List of project IDs") + url_list: list[str] = SchemaField(description="List of project URLs") + complete_data: list[dict] = SchemaField( + description="Complete project data including all fields" + ) + error: str = SchemaField(description="Error message if request failed") + + def __init__(self): + super().__init__( + id="5f3e1d5b-6bc5-40e3-97ee-1318b3f38813", + description="Gets all projects and their details from Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistListProjectsBlock.Input, + output_schema=TodoistListProjectsBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("names_list", ["Inbox"]), + ("ids_list", ["220474322"]), + ("url_list", ["https://todoist.com/showProject?id=220474322"]), + ( + "complete_data", + [ + { + "id": "220474322", + "name": "Inbox", + "url": "https://todoist.com/showProject?id=220474322", + } + ], + ), + ], + test_mock={ + "get_project_lists": lambda *args, **kwargs: ( + ["Inbox"], + ["220474322"], + ["https://todoist.com/showProject?id=220474322"], + [ + { + "id": "220474322", + "name": "Inbox", + "url": "https://todoist.com/showProject?id=220474322", + } + ], + None, + ) + }, + ) + + @staticmethod + def get_project_lists(credentials: TodoistCredentials): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + projects = api.get_projects() + + names = [] + ids = [] + urls = [] + complete_data = [] + + for project in projects: + names.append(project.name) + ids.append(project.id) + urls.append(project.url) + complete_data.append(project.__dict__) + + return names, ids, urls, complete_data, None + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + names, ids, urls, data, error = self.get_project_lists(credentials) + + if names: + yield "names_list", names + if ids: + yield "ids_list", ids + if urls: + yield "url_list", urls + if data: + yield "complete_data", data + + except Exception as e: + yield "error", str(e) + + +class TodoistCreateProjectBlock(Block): + """Creates a new project in Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + name: str = SchemaField(description="Name of the project", advanced=False) + parent_id: Optional[str] = SchemaField( + description="Parent project ID", default=None, advanced=True + ) + color: Optional[Colors] = SchemaField( + description="Color of the project icon", + default=Colors.charcoal, + advanced=True, + ) + is_favorite: bool = SchemaField( + description="Whether the project is a favorite", + default=False, + advanced=True, + ) + view_style: Optional[str] = SchemaField( + description="Display style (list or board)", default=None, advanced=True + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the creation was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="ade60136-de14-11ef-b5e5-32d3674e8b7e", + description="Creates a new project in Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistCreateProjectBlock.Input, + output_schema=TodoistCreateProjectBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={"credentials": TEST_CREDENTIALS_INPUT, "name": "Test Project"}, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"create_project": lambda *args, **kwargs: (True)}, + ) + + @staticmethod + def create_project( + credentials: TodoistCredentials, + name: str, + parent_id: Optional[str], + color: Optional[Colors], + is_favorite: bool, + view_style: Optional[str], + ): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + params = {"name": name, "is_favorite": is_favorite} + + if parent_id is not None: + params["parent_id"] = parent_id + if color is not None: + params["color"] = color.value + if view_style is not None: + params["view_style"] = view_style + + api.add_project(**params) + return True + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.create_project( + credentials=credentials, + name=input_data.name, + parent_id=input_data.parent_id, + color=input_data.color, + is_favorite=input_data.is_favorite, + view_style=input_data.view_style, + ) + + yield "success", success + + except Exception as e: + yield "error", str(e) + + +class TodoistGetProjectBlock(Block): + """Gets details for a specific Todoist project""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + project_id: str = SchemaField( + description="ID of the project to get details for", advanced=False + ) + + class Output(BlockSchema): + project_id: str = SchemaField(description="ID of project") + project_name: str = SchemaField(description="Name of project") + project_url: str = SchemaField(description="URL of project") + complete_data: dict = SchemaField( + description="Complete project data including all fields" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="b435b5ea-de14-11ef-8b51-32d3674e8b7e", + description="Gets details for a specific Todoist project", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistGetProjectBlock.Input, + output_schema=TodoistGetProjectBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "project_id": "2203306141", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("project_id", "2203306141"), + ("project_name", "Shopping List"), + ("project_url", "https://todoist.com/showProject?id=2203306141"), + ( + "complete_data", + { + "id": "2203306141", + "name": "Shopping List", + "url": "https://todoist.com/showProject?id=2203306141", + }, + ), + ], + test_mock={ + "get_project": lambda *args, **kwargs: ( + "2203306141", + "Shopping List", + "https://todoist.com/showProject?id=2203306141", + { + "id": "2203306141", + "name": "Shopping List", + "url": "https://todoist.com/showProject?id=2203306141", + }, + ) + }, + ) + + @staticmethod + def get_project(credentials: TodoistCredentials, project_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + project = api.get_project(project_id=project_id) + + return project.id, project.name, project.url, project.__dict__ + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + project_id, project_name, project_url, data = self.get_project( + credentials=credentials, project_id=input_data.project_id + ) + + if project_id: + yield "project_id", project_id + if project_name: + yield "project_name", project_name + if project_url: + yield "project_url", project_url + if data: + yield "complete_data", data + + except Exception as e: + yield "error", str(e) + + +class TodoistUpdateProjectBlock(Block): + """Updates an existing project in Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + project_id: str = SchemaField( + description="ID of project to update", advanced=False + ) + name: Optional[str] = SchemaField( + description="New name for the project", default=None, advanced=False + ) + color: Optional[Colors] = SchemaField( + description="New color for the project icon", default=None, advanced=True + ) + is_favorite: Optional[bool] = SchemaField( + description="Whether the project should be a favorite", + default=None, + advanced=True, + ) + view_style: Optional[str] = SchemaField( + description="Display style (list or board)", default=None, advanced=True + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the update was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="ba41a20a-de14-11ef-91d7-32d3674e8b7e", + description="Updates an existing project in Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistUpdateProjectBlock.Input, + output_schema=TodoistUpdateProjectBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "project_id": "2203306141", + "name": "Things To Buy", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"update_project": lambda *args, **kwargs: (True)}, + ) + + @staticmethod + def update_project( + credentials: TodoistCredentials, + project_id: str, + name: Optional[str], + color: Optional[Colors], + is_favorite: Optional[bool], + view_style: Optional[str], + ): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + params = {} + + if name is not None: + params["name"] = name + if color is not None: + params["color"] = color.value + if is_favorite is not None: + params["is_favorite"] = is_favorite + if view_style is not None: + params["view_style"] = view_style + + api.update_project(project_id=project_id, **params) + return True + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.update_project( + credentials=credentials, + project_id=input_data.project_id, + name=input_data.name, + color=input_data.color, + is_favorite=input_data.is_favorite, + view_style=input_data.view_style, + ) + + yield "success", success + + except Exception as e: + yield "error", str(e) + + +class TodoistDeleteProjectBlock(Block): + """Deletes a project and all of its sections and tasks""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + project_id: str = SchemaField( + description="ID of project to delete", advanced=False + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the deletion was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="c2893acc-de14-11ef-a113-32d3674e8b7e", + description="Deletes a Todoist project and all its contents", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistDeleteProjectBlock.Input, + output_schema=TodoistDeleteProjectBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "project_id": "2203306141", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"delete_project": lambda *args, **kwargs: (True)}, + ) + + @staticmethod + def delete_project(credentials: TodoistCredentials, project_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + success = api.delete_project(project_id=project_id) + return success + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.delete_project( + credentials=credentials, project_id=input_data.project_id + ) + + yield "success", success + + except Exception as e: + yield "error", str(e) + + +class TodoistListCollaboratorsBlock(Block): + """Gets all collaborators for a Todoist project""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + project_id: str = SchemaField( + description="ID of the project to get collaborators for", advanced=False + ) + + class Output(BlockSchema): + collaborator_ids: list[str] = SchemaField( + description="List of collaborator IDs" + ) + collaborator_names: list[str] = SchemaField( + description="List of collaborator names" + ) + collaborator_emails: list[str] = SchemaField( + description="List of collaborator email addresses" + ) + complete_data: list[dict] = SchemaField( + description="Complete collaborator data including all fields" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="c99c804e-de14-11ef-9f47-32d3674e8b7e", + description="Gets all collaborators for a specific Todoist project", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistListCollaboratorsBlock.Input, + output_schema=TodoistListCollaboratorsBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "project_id": "2203306141", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("collaborator_ids", ["2671362", "2671366"]), + ("collaborator_names", ["Alice", "Bob"]), + ("collaborator_emails", ["alice@example.com", "bob@example.com"]), + ( + "complete_data", + [ + { + "id": "2671362", + "name": "Alice", + "email": "alice@example.com", + }, + {"id": "2671366", "name": "Bob", "email": "bob@example.com"}, + ], + ), + ], + test_mock={ + "get_collaborators": lambda *args, **kwargs: ( + ["2671362", "2671366"], + ["Alice", "Bob"], + ["alice@example.com", "bob@example.com"], + [ + { + "id": "2671362", + "name": "Alice", + "email": "alice@example.com", + }, + {"id": "2671366", "name": "Bob", "email": "bob@example.com"}, + ], + ) + }, + ) + + @staticmethod + def get_collaborators(credentials: TodoistCredentials, project_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + collaborators = api.get_collaborators(project_id=project_id) + + ids = [] + names = [] + emails = [] + complete_data = [] + + for collaborator in collaborators: + ids.append(collaborator.id) + names.append(collaborator.name) + emails.append(collaborator.email) + complete_data.append(collaborator.__dict__) + + return ids, names, emails, complete_data + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, names, emails, data = self.get_collaborators( + credentials=credentials, project_id=input_data.project_id + ) + + if ids: + yield "collaborator_ids", ids + if names: + yield "collaborator_names", names + if emails: + yield "collaborator_emails", emails + if data: + yield "complete_data", data + + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/todoist/sections.py b/autogpt_platform/backend/backend/blocks/todoist/sections.py new file mode 100644 index 000000000000..764f7e166ecf --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/todoist/sections.py @@ -0,0 +1,310 @@ +from todoist_api_python.api import TodoistAPI +from typing_extensions import Optional + +from backend.blocks.todoist._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TODOIST_OAUTH_IS_CONFIGURED, + TodoistCredentials, + TodoistCredentialsField, + TodoistCredentialsInput, +) +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TodoistListSectionsBlock(Block): + """Gets all sections for a Todoist project""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + project_id: Optional[str] = SchemaField( + description="Optional project ID to filter sections" + ) + + class Output(BlockSchema): + names_list: list[str] = SchemaField(description="List of section names") + ids_list: list[str] = SchemaField(description="List of section IDs") + complete_data: list[dict] = SchemaField( + description="Complete section data including all fields" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="d6a116d8-de14-11ef-a94c-32d3674e8b7e", + description="Gets all sections and their details from Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistListSectionsBlock.Input, + output_schema=TodoistListSectionsBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "project_id": "2203306141", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("names_list", ["Groceries"]), + ("ids_list", ["7025"]), + ( + "complete_data", + [ + { + "id": "7025", + "project_id": "2203306141", + "order": 1, + "name": "Groceries", + } + ], + ), + ], + test_mock={ + "get_section_lists": lambda *args, **kwargs: ( + ["Groceries"], + ["7025"], + [ + { + "id": "7025", + "project_id": "2203306141", + "order": 1, + "name": "Groceries", + } + ], + ) + }, + ) + + @staticmethod + def get_section_lists( + credentials: TodoistCredentials, project_id: Optional[str] = None + ): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + sections = api.get_sections(project_id=project_id) + + names = [] + ids = [] + complete_data = [] + + for section in sections: + names.append(section.name) + ids.append(section.id) + complete_data.append(section.__dict__) + + return names, ids, complete_data + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + names, ids, data = self.get_section_lists( + credentials, input_data.project_id + ) + + if names: + yield "names_list", names + if ids: + yield "ids_list", ids + if data: + yield "complete_data", data + + except Exception as e: + yield "error", str(e) + + +# Error in official todoist SDK. Will add this block using sync_api +# class TodoistCreateSectionBlock(Block): +# """Creates a new section in a Todoist project""" + +# class Input(BlockSchema): +# credentials: TodoistCredentialsInput = TodoistCredentialsField([]) +# name: str = SchemaField(description="Section name") +# project_id: str = SchemaField(description="Project ID this section should belong to") +# order: Optional[int] = SchemaField(description="Optional order among other sections", default=None) + +# class Output(BlockSchema): +# success: bool = SchemaField(description="Whether section was successfully created") +# error: str = SchemaField(description="Error message if the request failed") + +# def __init__(self): +# super().__init__( +# id="e3025cfc-de14-11ef-b9f2-32d3674e8b7e", +# description="Creates a new section in a Todoist project", +# categories={BlockCategory.PRODUCTIVITY}, +# input_schema=TodoistCreateSectionBlock.Input, +# output_schema=TodoistCreateSectionBlock.Output, +# test_input={ +# "credentials": TEST_CREDENTIALS_INPUT, +# "name": "Groceries", +# "project_id": "2203306141" +# }, +# test_credentials=TEST_CREDENTIALS, +# test_output=[ +# ("success", True) +# ], +# test_mock={ +# "create_section": lambda *args, **kwargs: ( +# {"id": "7025", "project_id": "2203306141", "order": 1, "name": "Groceries"}, +# ) +# }, +# ) + +# @staticmethod +# def create_section(credentials: TodoistCredentials, name: str, project_id: str, order: Optional[int] = None): +# try: +# api = TodoistAPI(credentials.access_token.get_secret_value()) +# section = api.add_section(name=name, project_id=project_id, order=order) +# return section.__dict__ + +# except Exception as e: +# raise e + +# async def run( +# self, +# input_data: Input, +# *, +# credentials: TodoistCredentials, +# **kwargs, +# ) -> BlockOutput: +# try: +# section_data = self.create_section( +# credentials, +# input_data.name, +# input_data.project_id, +# input_data.order +# ) + +# if section_data: +# yield "success", True + +# except Exception as e: +# yield "error", str(e) + + +class TodoistGetSectionBlock(Block): + """Gets a single section from Todoist by ID""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + section_id: str = SchemaField(description="ID of section to fetch") + + class Output(BlockSchema): + id: str = SchemaField(description="ID of section") + project_id: str = SchemaField(description="Project ID the section belongs to") + order: int = SchemaField(description="Order of the section") + name: str = SchemaField(description="Name of the section") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="ea5580e2-de14-11ef-a5d3-32d3674e8b7e", + description="Gets a single section by ID from Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistGetSectionBlock.Input, + output_schema=TodoistGetSectionBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={"credentials": TEST_CREDENTIALS_INPUT, "section_id": "7025"}, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", "7025"), + ("project_id", "2203306141"), + ("order", 1), + ("name", "Groceries"), + ], + test_mock={ + "get_section": lambda *args, **kwargs: { + "id": "7025", + "project_id": "2203306141", + "order": 1, + "name": "Groceries", + } + }, + ) + + @staticmethod + def get_section(credentials: TodoistCredentials, section_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + section = api.get_section(section_id=section_id) + return section.__dict__ + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + section_data = self.get_section(credentials, input_data.section_id) + + if section_data: + yield "id", section_data["id"] + yield "project_id", section_data["project_id"] + yield "order", section_data["order"] + yield "name", section_data["name"] + + except Exception as e: + yield "error", str(e) + + +class TodoistDeleteSectionBlock(Block): + """Deletes a section and all its tasks from Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + section_id: str = SchemaField(description="ID of section to delete") + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether section was successfully deleted" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="f0e52eee-de14-11ef-9b12-32d3674e8b7e", + description="Deletes a section and all its tasks from Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistDeleteSectionBlock.Input, + output_schema=TodoistDeleteSectionBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={"credentials": TEST_CREDENTIALS_INPUT, "section_id": "7025"}, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"delete_section": lambda *args, **kwargs: (True)}, + ) + + @staticmethod + def delete_section(credentials: TodoistCredentials, section_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + success = api.delete_section(section_id=section_id) + return success + + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.delete_section(credentials, input_data.section_id) + yield "success", success + + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/todoist/tasks.py b/autogpt_platform/backend/backend/blocks/todoist/tasks.py new file mode 100644 index 000000000000..d50124a9ef13 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/todoist/tasks.py @@ -0,0 +1,660 @@ +from datetime import datetime + +from todoist_api_python.api import TodoistAPI +from todoist_api_python.models import Task +from typing_extensions import Optional + +from backend.blocks.todoist._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TODOIST_OAUTH_IS_CONFIGURED, + TodoistCredentials, + TodoistCredentialsField, + TodoistCredentialsInput, +) +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TodoistCreateTaskBlock(Block): + """Creates a new task in a Todoist project""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + content: str = SchemaField(description="Task content", advanced=False) + description: Optional[str] = SchemaField( + description="Task description", default=None, advanced=False + ) + project_id: Optional[str] = SchemaField( + description="Project ID this task should belong to", + default=None, + advanced=False, + ) + section_id: Optional[str] = SchemaField( + description="Section ID this task should belong to", + default=None, + advanced=False, + ) + parent_id: Optional[str] = SchemaField( + description="Parent task ID", default=None, advanced=True + ) + order: Optional[int] = SchemaField( + description="Optional order among other tasks,[Non-zero integer value used by clients to sort tasks under the same parent]", + default=None, + advanced=True, + ) + labels: Optional[list[str]] = SchemaField( + description="Task labels", default=None, advanced=True + ) + priority: Optional[int] = SchemaField( + description="Task priority from 1 (normal) to 4 (urgent)", + default=None, + advanced=True, + ) + due_date: Optional[datetime] = SchemaField( + description="Due date in YYYY-MM-DD format", advanced=True, default=None + ) + deadline_date: Optional[datetime] = SchemaField( + description="Specific date in YYYY-MM-DD format relative to user's timezone", + default=None, + advanced=True, + ) + assignee_id: Optional[str] = SchemaField( + description="Responsible user ID", default=None, advanced=True + ) + duration_unit: Optional[str] = SchemaField( + description="Task duration unit (minute/day)", default=None, advanced=True + ) + duration: Optional[int] = SchemaField( + description="Task duration amount, You need to selecct the duration unit first", + depends_on=["duration_unit"], + default=None, + advanced=True, + ) + + class Output(BlockSchema): + id: str = SchemaField(description="Task ID") + url: str = SchemaField(description="Task URL") + complete_data: dict = SchemaField( + description="Complete task data as dictionary" + ) + error: str = SchemaField(description="Error message if request failed") + + def __init__(self): + super().__init__( + id="fde4f458-de14-11ef-bf0c-32d3674e8b7e", + description="Creates a new task in a Todoist project", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistCreateTaskBlock.Input, + output_schema=TodoistCreateTaskBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "content": "Buy groceries", + "project_id": "2203306141", + "priority": 4, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", "2995104339"), + ("url", "https://todoist.com/showTask?id=2995104339"), + ( + "complete_data", + { + "id": "2995104339", + "project_id": "2203306141", + "url": "https://todoist.com/showTask?id=2995104339", + }, + ), + ], + test_mock={ + "create_task": lambda *args, **kwargs: ( + "2995104339", + "https://todoist.com/showTask?id=2995104339", + { + "id": "2995104339", + "project_id": "2203306141", + "url": "https://todoist.com/showTask?id=2995104339", + }, + ) + }, + ) + + @staticmethod + def create_task(credentials: TodoistCredentials, content: str, **kwargs): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + task = api.add_task(content=content, **kwargs) + task_dict = Task.to_dict(task) + return task.id, task.url, task_dict + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + due_date = ( + input_data.due_date.strftime("%Y-%m-%d") + if input_data.due_date + else None + ) + deadline_date = ( + input_data.deadline_date.strftime("%Y-%m-%d") + if input_data.deadline_date + else None + ) + + task_args = { + "description": input_data.description, + "project_id": input_data.project_id, + "section_id": input_data.section_id, + "parent_id": input_data.parent_id, + "order": input_data.order, + "labels": input_data.labels, + "priority": input_data.priority, + "due_date": due_date, + "deadline_date": deadline_date, + "assignee_id": input_data.assignee_id, + "duration": input_data.duration, + "duration_unit": input_data.duration_unit, + } + + id, url, complete_data = self.create_task( + credentials, + input_data.content, + **{k: v for k, v in task_args.items() if v is not None}, + ) + + yield "id", id + yield "url", url + yield "complete_data", complete_data + + except Exception as e: + yield "error", str(e) + + +class TodoistGetTasksBlock(Block): + """Get active tasks from Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + project_id: Optional[str] = SchemaField( + description="Filter tasks by project ID", default=None, advanced=False + ) + section_id: Optional[str] = SchemaField( + description="Filter tasks by section ID", default=None, advanced=True + ) + label: Optional[str] = SchemaField( + description="Filter tasks by label name", default=None, advanced=True + ) + filter: Optional[str] = SchemaField( + description="Filter by any supported filter, You can see How to use filters or create one of your one here - https://todoist.com/help/articles/introduction-to-filters-V98wIH", + default=None, + advanced=True, + ) + lang: Optional[str] = SchemaField( + description="IETF language tag for filter language", default=None + ) + ids: Optional[list[str]] = SchemaField( + description="List of task IDs to retrieve", default=None, advanced=False + ) + + class Output(BlockSchema): + ids: list[str] = SchemaField(description="Task IDs") + urls: list[str] = SchemaField(description="Task URLs") + complete_data: list[dict] = SchemaField( + description="Complete task data as dictionary" + ) + error: str = SchemaField(description="Error message if request failed") + + def __init__(self): + super().__init__( + id="0b706e86-de15-11ef-a113-32d3674e8b7e", + description="Get active tasks from Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistGetTasksBlock.Input, + output_schema=TodoistGetTasksBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "project_id": "2203306141", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["2995104339"]), + ("urls", ["https://todoist.com/showTask?id=2995104339"]), + ( + "complete_data", + [ + { + "id": "2995104339", + "project_id": "2203306141", + "url": "https://todoist.com/showTask?id=2995104339", + "is_completed": False, + } + ], + ), + ], + test_mock={ + "get_tasks": lambda *args, **kwargs: [ + { + "id": "2995104339", + "project_id": "2203306141", + "url": "https://todoist.com/showTask?id=2995104339", + "is_completed": False, + } + ] + }, + ) + + @staticmethod + def get_tasks(credentials: TodoistCredentials, **kwargs): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + tasks = api.get_tasks(**kwargs) + return [Task.to_dict(task) for task in tasks] + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + task_filters = { + "project_id": input_data.project_id, + "section_id": input_data.section_id, + "label": input_data.label, + "filter": input_data.filter, + "lang": input_data.lang, + "ids": input_data.ids, + } + + tasks = self.get_tasks( + credentials, **{k: v for k, v in task_filters.items() if v is not None} + ) + + yield "ids", [task["id"] for task in tasks] + yield "urls", [task["url"] for task in tasks] + yield "complete_data", tasks + + except Exception as e: + yield "error", str(e) + + +class TodoistGetTaskBlock(Block): + """Get an active task from Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + task_id: str = SchemaField(description="Task ID to retrieve") + + class Output(BlockSchema): + project_id: str = SchemaField(description="Project ID containing the task") + url: str = SchemaField(description="Task URL") + complete_data: dict = SchemaField( + description="Complete task data as dictionary" + ) + error: str = SchemaField(description="Error message if request failed") + + def __init__(self): + super().__init__( + id="16d7dc8c-de15-11ef-8ace-32d3674e8b7e", + description="Get an active task from Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistGetTaskBlock.Input, + output_schema=TodoistGetTaskBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={"credentials": TEST_CREDENTIALS_INPUT, "task_id": "2995104339"}, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("project_id", "2203306141"), + ("url", "https://todoist.com/showTask?id=2995104339"), + ( + "complete_data", + { + "id": "2995104339", + "project_id": "2203306141", + "url": "https://todoist.com/showTask?id=2995104339", + }, + ), + ], + test_mock={ + "get_task": lambda *args, **kwargs: { + "project_id": "2203306141", + "id": "2995104339", + "url": "https://todoist.com/showTask?id=2995104339", + } + }, + ) + + @staticmethod + def get_task(credentials: TodoistCredentials, task_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + task = api.get_task(task_id=task_id) + return Task.to_dict(task) + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + task_data = self.get_task(credentials, input_data.task_id) + + if task_data: + yield "project_id", task_data["project_id"] + yield "url", task_data["url"] + yield "complete_data", task_data + + except Exception as e: + yield "error", str(e) + + +class TodoistUpdateTaskBlock(Block): + """Updates an existing task in Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + task_id: str = SchemaField(description="Task ID to update") + content: str = SchemaField(description="Task content", advanced=False) + description: Optional[str] = SchemaField( + description="Task description", default=None, advanced=False + ) + project_id: Optional[str] = SchemaField( + description="Project ID this task should belong to", + default=None, + advanced=False, + ) + section_id: Optional[str] = SchemaField( + description="Section ID this task should belong to", + default=None, + advanced=False, + ) + parent_id: Optional[str] = SchemaField( + description="Parent task ID", default=None, advanced=True + ) + order: Optional[int] = SchemaField( + description="Optional order among other tasks,[Non-zero integer value used by clients to sort tasks under the same parent]", + default=None, + advanced=True, + ) + labels: Optional[list[str]] = SchemaField( + description="Task labels", default=None, advanced=True + ) + priority: Optional[int] = SchemaField( + description="Task priority from 1 (normal) to 4 (urgent)", + default=None, + advanced=True, + ) + due_date: Optional[datetime] = SchemaField( + description="Due date in YYYY-MM-DD format", advanced=True, default=None + ) + deadline_date: Optional[datetime] = SchemaField( + description="Specific date in YYYY-MM-DD format relative to user's timezone", + default=None, + advanced=True, + ) + assignee_id: Optional[str] = SchemaField( + description="Responsible user ID", default=None, advanced=True + ) + duration_unit: Optional[str] = SchemaField( + description="Task duration unit (minute/day)", default=None, advanced=True + ) + duration: Optional[int] = SchemaField( + description="Task duration amount, You need to selecct the duration unit first", + depends_on=["duration_unit"], + default=None, + advanced=True, + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the update was successful") + error: str = SchemaField(description="Error message if request failed") + + def __init__(self): + super().__init__( + id="1eee6d32-de15-11ef-a2ff-32d3674e8b7e", + description="Updates an existing task in Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistUpdateTaskBlock.Input, + output_schema=TodoistUpdateTaskBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "task_id": "2995104339", + "content": "Buy Coffee", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"update_task": lambda *args, **kwargs: True}, + ) + + @staticmethod + def update_task(credentials: TodoistCredentials, task_id: str, **kwargs): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + is_success = api.update_task(task_id=task_id, **kwargs) + return is_success + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + due_date = ( + input_data.due_date.strftime("%Y-%m-%d") + if input_data.due_date + else None + ) + deadline_date = ( + input_data.deadline_date.strftime("%Y-%m-%d") + if input_data.deadline_date + else None + ) + + task_updates = {} + update_fields = { + "content": input_data.content, + "description": input_data.description, + "project_id": input_data.project_id, + "section_id": input_data.section_id, + "parent_id": input_data.parent_id, + "order": input_data.order, + "labels": input_data.labels, + "priority": input_data.priority, + "due_date": due_date, + "deadline_date": deadline_date, + "assignee_id": input_data.assignee_id, + "duration": input_data.duration, + "duration_unit": input_data.duration_unit, + } + + # Filter out None values + task_updates = {k: v for k, v in update_fields.items() if v is not None} + + self.update_task( + credentials, + input_data.task_id, + **{k: v for k, v in task_updates.items() if v is not None}, + ) + + yield "success", True + + except Exception as e: + yield "error", str(e) + + +class TodoistCloseTaskBlock(Block): + """Closes a task in Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + task_id: str = SchemaField(description="Task ID to close") + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the task was successfully closed" + ) + error: str = SchemaField(description="Error message if request failed") + + def __init__(self): + super().__init__( + id="29fac798-de15-11ef-b839-32d3674e8b7e", + description="Closes a task in Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistCloseTaskBlock.Input, + output_schema=TodoistCloseTaskBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={"credentials": TEST_CREDENTIALS_INPUT, "task_id": "2995104339"}, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"close_task": lambda *args, **kwargs: True}, + ) + + @staticmethod + def close_task(credentials: TodoistCredentials, task_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + is_success = api.close_task(task_id=task_id) + return is_success + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + is_success = self.close_task(credentials, input_data.task_id) + yield "success", is_success + + except Exception as e: + yield "error", str(e) + + +class TodoistReopenTaskBlock(Block): + """Reopens a task in Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + task_id: str = SchemaField(description="Task ID to reopen") + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the task was successfully reopened" + ) + error: str = SchemaField(description="Error message if request failed") + + def __init__(self): + super().__init__( + id="2e6bf6f8-de15-11ef-ae7c-32d3674e8b7e", + description="Reopens a task in Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistReopenTaskBlock.Input, + output_schema=TodoistReopenTaskBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={"credentials": TEST_CREDENTIALS_INPUT, "task_id": "2995104339"}, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"reopen_task": lambda *args, **kwargs: (True)}, + ) + + @staticmethod + def reopen_task(credentials: TodoistCredentials, task_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + is_success = api.reopen_task(task_id=task_id) + return is_success + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + is_success = self.reopen_task(credentials, input_data.task_id) + yield "success", is_success + + except Exception as e: + yield "error", str(e) + + +class TodoistDeleteTaskBlock(Block): + """Deletes a task in Todoist""" + + class Input(BlockSchema): + credentials: TodoistCredentialsInput = TodoistCredentialsField([]) + task_id: str = SchemaField(description="Task ID to delete") + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the task was successfully deleted" + ) + error: str = SchemaField(description="Error message if request failed") + + def __init__(self): + super().__init__( + id="33c29ada-de15-11ef-bcbb-32d3674e8b7e", + description="Deletes a task in Todoist", + categories={BlockCategory.PRODUCTIVITY}, + input_schema=TodoistDeleteTaskBlock.Input, + output_schema=TodoistDeleteTaskBlock.Output, + disabled=not TODOIST_OAUTH_IS_CONFIGURED, + test_input={"credentials": TEST_CREDENTIALS_INPUT, "task_id": "2995104339"}, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"delete_task": lambda *args, **kwargs: (True)}, + ) + + @staticmethod + def delete_task(credentials: TodoistCredentials, task_id: str): + try: + api = TodoistAPI(credentials.access_token.get_secret_value()) + is_success = api.delete_task(task_id=task_id) + return is_success + except Exception as e: + raise e + + async def run( + self, + input_data: Input, + *, + credentials: TodoistCredentials, + **kwargs, + ) -> BlockOutput: + try: + is_success = self.delete_task(credentials, input_data.task_id) + yield "success", is_success + + except Exception as e: + yield "error", str(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/_auth.py b/autogpt_platform/backend/backend/blocks/twitter/_auth.py new file mode 100644 index 000000000000..0bff03fa370a --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/_auth.py @@ -0,0 +1,60 @@ +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import ( + CredentialsField, + CredentialsMetaInput, + OAuth2Credentials, + ProviderName, +) +from backend.integrations.oauth.twitter import TwitterOAuthHandler +from backend.util.settings import Secrets + +# --8<-- [start:TwitterOAuthIsConfigured] +secrets = Secrets() +TWITTER_OAUTH_IS_CONFIGURED = bool( + secrets.twitter_client_id and secrets.twitter_client_secret +) +# --8<-- [end:TwitterOAuthIsConfigured] + +TwitterCredentials = OAuth2Credentials +TwitterCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.TWITTER], Literal["oauth2"] +] + + +# Currently, We are getting all the permission from the Twitter API initally +# In future, If we need to add incremental permission, we can use these requested_scopes +def TwitterCredentialsField(scopes: list[str]) -> TwitterCredentialsInput: + """ + Creates a Twitter credentials input on a block. + + Params: + scopes: The authorization scopes needed for the block to work. + """ + return CredentialsField( + # required_scopes=set(scopes), + required_scopes=set(TwitterOAuthHandler.DEFAULT_SCOPES + scopes), + description="The Twitter integration requires OAuth2 authentication.", + ) + + +TEST_CREDENTIALS = OAuth2Credentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="twitter", + access_token=SecretStr("mock-twitter-access-token"), + refresh_token=SecretStr("mock-twitter-refresh-token"), + access_token_expires_at=1234567890, + scopes=["tweet.read", "tweet.write", "users.read", "offline.access"], + title="Mock Twitter OAuth2 Credentials", + username="mock-twitter-username", + refresh_token_expires_at=1234567890, +) + +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} diff --git a/autogpt_platform/backend/backend/blocks/twitter/_builders.py b/autogpt_platform/backend/backend/blocks/twitter/_builders.py new file mode 100644 index 000000000000..6dc450c2474f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/_builders.py @@ -0,0 +1,418 @@ +from datetime import datetime +from typing import Any, Dict + +from backend.blocks.twitter._mappers import ( + get_backend_expansion, + get_backend_field, + get_backend_list_expansion, + get_backend_list_field, + get_backend_media_field, + get_backend_place_field, + get_backend_poll_field, + get_backend_space_expansion, + get_backend_space_field, + get_backend_user_field, +) +from backend.blocks.twitter._types import ( # DMEventFieldFilter, + DMEventExpansionFilter, + DMEventTypeFilter, + DMMediaFieldFilter, + DMTweetFieldFilter, + ExpansionFilter, + ListExpansionsFilter, + ListFieldsFilter, + SpaceExpansionsFilter, + SpaceFieldsFilter, + TweetFieldsFilter, + TweetMediaFieldsFilter, + TweetPlaceFieldsFilter, + TweetPollFieldsFilter, + TweetReplySettingsFilter, + TweetUserFieldsFilter, + UserExpansionsFilter, +) + + +# Common Builder +class TweetExpansionsBuilder: + def __init__(self, param: Dict[str, Any]): + self.params: Dict[str, Any] = param + + def add_expansions(self, expansions: ExpansionFilter | None): + if expansions: + filtered_expansions = [ + name for name, value in expansions.dict().items() if value is True + ] + + if filtered_expansions: + self.params["expansions"] = ",".join( + [get_backend_expansion(exp) for exp in filtered_expansions] + ) + + return self + + def add_media_fields(self, media_fields: TweetMediaFieldsFilter | None): + if media_fields: + filtered_fields = [ + name for name, value in media_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["media.fields"] = ",".join( + [get_backend_media_field(field) for field in filtered_fields] + ) + return self + + def add_place_fields(self, place_fields: TweetPlaceFieldsFilter | None): + if place_fields: + filtered_fields = [ + name for name, value in place_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["place.fields"] = ",".join( + [get_backend_place_field(field) for field in filtered_fields] + ) + return self + + def add_poll_fields(self, poll_fields: TweetPollFieldsFilter | None): + if poll_fields: + filtered_fields = [ + name for name, value in poll_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["poll.fields"] = ",".join( + [get_backend_poll_field(field) for field in filtered_fields] + ) + return self + + def add_tweet_fields(self, tweet_fields: TweetFieldsFilter | None): + if tweet_fields: + filtered_fields = [ + name for name, value in tweet_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["tweet.fields"] = ",".join( + [get_backend_field(field) for field in filtered_fields] + ) + return self + + def add_user_fields(self, user_fields: TweetUserFieldsFilter | None): + if user_fields: + filtered_fields = [ + name for name, value in user_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["user.fields"] = ",".join( + [get_backend_user_field(field) for field in filtered_fields] + ) + return self + + def build(self): + return self.params + + +class UserExpansionsBuilder: + def __init__(self, param: Dict[str, Any]): + self.params: Dict[str, Any] = param + + def add_expansions(self, expansions: UserExpansionsFilter | None): + if expansions: + filtered_expansions = [ + name for name, value in expansions.dict().items() if value is True + ] + if filtered_expansions: + self.params["expansions"] = ",".join(filtered_expansions) + return self + + def add_tweet_fields(self, tweet_fields: TweetFieldsFilter | None): + if tweet_fields: + filtered_fields = [ + name for name, value in tweet_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["tweet.fields"] = ",".join( + [get_backend_field(field) for field in filtered_fields] + ) + return self + + def add_user_fields(self, user_fields: TweetUserFieldsFilter | None): + if user_fields: + filtered_fields = [ + name for name, value in user_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["user.fields"] = ",".join( + [get_backend_user_field(field) for field in filtered_fields] + ) + return self + + def build(self): + return self.params + + +class ListExpansionsBuilder: + def __init__(self, param: Dict[str, Any]): + self.params: Dict[str, Any] = param + + def add_expansions(self, expansions: ListExpansionsFilter | None): + if expansions: + filtered_expansions = [ + name for name, value in expansions.dict().items() if value is True + ] + if filtered_expansions: + self.params["expansions"] = ",".join( + [get_backend_list_expansion(exp) for exp in filtered_expansions] + ) + return self + + def add_list_fields(self, list_fields: ListFieldsFilter | None): + if list_fields: + filtered_fields = [ + name for name, value in list_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["list.fields"] = ",".join( + [get_backend_list_field(field) for field in filtered_fields] + ) + return self + + def add_user_fields(self, user_fields: TweetUserFieldsFilter | None): + if user_fields: + filtered_fields = [ + name for name, value in user_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["user.fields"] = ",".join( + [get_backend_user_field(field) for field in filtered_fields] + ) + return self + + def build(self): + return self.params + + +class SpaceExpansionsBuilder: + def __init__(self, param: Dict[str, Any]): + self.params: Dict[str, Any] = param + + def add_expansions(self, expansions: SpaceExpansionsFilter | None): + if expansions: + filtered_expansions = [ + name for name, value in expansions.dict().items() if value is True + ] + if filtered_expansions: + self.params["expansions"] = ",".join( + [get_backend_space_expansion(exp) for exp in filtered_expansions] + ) + return self + + def add_space_fields(self, space_fields: SpaceFieldsFilter | None): + if space_fields: + filtered_fields = [ + name for name, value in space_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["space.fields"] = ",".join( + [get_backend_space_field(field) for field in filtered_fields] + ) + return self + + def add_user_fields(self, user_fields: TweetUserFieldsFilter | None): + if user_fields: + filtered_fields = [ + name for name, value in user_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["user.fields"] = ",".join( + [get_backend_user_field(field) for field in filtered_fields] + ) + return self + + def build(self): + return self.params + + +class TweetDurationBuilder: + def __init__(self, param: Dict[str, Any]): + self.params: Dict[str, Any] = param + + def add_start_time(self, start_time: datetime | None): + if start_time: + self.params["start_time"] = start_time + return self + + def add_end_time(self, end_time: datetime | None): + if end_time: + self.params["end_time"] = end_time + return self + + def add_since_id(self, since_id: str | None): + if since_id: + self.params["since_id"] = since_id + return self + + def add_until_id(self, until_id: str | None): + if until_id: + self.params["until_id"] = until_id + return self + + def add_sort_order(self, sort_order: str | None): + if sort_order: + self.params["sort_order"] = sort_order + return self + + def build(self): + return self.params + + +class DMExpansionsBuilder: + def __init__(self, param: Dict[str, Any]): + self.params: Dict[str, Any] = param + + def add_expansions(self, expansions: DMEventExpansionFilter): + if expansions: + filtered_expansions = [ + name for name, value in expansions.dict().items() if value is True + ] + if filtered_expansions: + self.params["expansions"] = ",".join(filtered_expansions) + return self + + def add_event_types(self, event_types: DMEventTypeFilter): + if event_types: + filtered_types = [ + name for name, value in event_types.dict().items() if value is True + ] + if filtered_types: + self.params["event_types"] = ",".join(filtered_types) + return self + + def add_media_fields(self, media_fields: DMMediaFieldFilter): + if media_fields: + filtered_fields = [ + name for name, value in media_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["media.fields"] = ",".join(filtered_fields) + return self + + def add_tweet_fields(self, tweet_fields: DMTweetFieldFilter): + if tweet_fields: + filtered_fields = [ + name for name, value in tweet_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["tweet.fields"] = ",".join(filtered_fields) + return self + + def add_user_fields(self, user_fields: TweetUserFieldsFilter): + if user_fields: + filtered_fields = [ + name for name, value in user_fields.dict().items() if value is True + ] + if filtered_fields: + self.params["user.fields"] = ",".join(filtered_fields) + return self + + def build(self): + return self.params + + +# Specific Builders +class TweetSearchBuilder: + def __init__(self): + self.params: Dict[str, Any] = {"user_auth": False} + + def add_query(self, query: str): + if query: + self.params["query"] = query + return self + + def add_pagination(self, max_results: int, pagination: str | None): + if max_results: + self.params["max_results"] = max_results + if pagination: + self.params["pagination_token"] = pagination + return self + + def build(self): + return self.params + + +class TweetPostBuilder: + def __init__(self): + self.params: Dict[str, Any] = {"user_auth": False} + + def add_text(self, text: str | None): + if text: + self.params["text"] = text + return self + + def add_media(self, media_ids: list, tagged_user_ids: list): + if media_ids: + self.params["media_ids"] = media_ids + if tagged_user_ids: + self.params["media_tagged_user_ids"] = tagged_user_ids + return self + + def add_deep_link(self, link: str): + if link: + self.params["direct_message_deep_link"] = link + return self + + def add_super_followers(self, for_super_followers: bool): + if for_super_followers: + self.params["for_super_followers_only"] = for_super_followers + return self + + def add_place(self, place_id: str): + if place_id: + self.params["place_id"] = place_id + return self + + def add_poll_options(self, poll_options: list): + if poll_options: + self.params["poll_options"] = poll_options + return self + + def add_poll_duration(self, poll_duration_minutes: int): + if poll_duration_minutes: + self.params["poll_duration_minutes"] = poll_duration_minutes + return self + + def add_quote(self, quote_id: str): + if quote_id: + self.params["quote_tweet_id"] = quote_id + return self + + def add_reply_settings( + self, + exclude_user_ids: list, + reply_to_id: str, + settings: TweetReplySettingsFilter, + ): + if exclude_user_ids: + self.params["exclude_reply_user_ids"] = exclude_user_ids + if reply_to_id: + self.params["in_reply_to_tweet_id"] = reply_to_id + if settings.All_Users: + self.params["reply_settings"] = None + elif settings.Following_Users_Only: + self.params["reply_settings"] = "following" + elif settings.Mentioned_Users_Only: + self.params["reply_settings"] = "mentionedUsers" + return self + + def build(self): + return self.params + + +class TweetGetsBuilder: + def __init__(self): + self.params: Dict[str, Any] = {"user_auth": False} + + def add_id(self, tweet_id: list[str]): + self.params["id"] = tweet_id + return self + + def build(self): + return self.params diff --git a/autogpt_platform/backend/backend/blocks/twitter/_mappers.py b/autogpt_platform/backend/backend/blocks/twitter/_mappers.py new file mode 100644 index 000000000000..a564174ed0f2 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/_mappers.py @@ -0,0 +1,234 @@ +# -------------- Tweets ----------------- + +# Tweet Expansions +EXPANSION_FRONTEND_TO_BACKEND_MAPPING = { + "Poll_IDs": "attachments.poll_ids", + "Media_Keys": "attachments.media_keys", + "Author_User_ID": "author_id", + "Edit_History_Tweet_IDs": "edit_history_tweet_ids", + "Mentioned_Usernames": "entities.mentions.username", + "Place_ID": "geo.place_id", + "Reply_To_User_ID": "in_reply_to_user_id", + "Referenced_Tweet_ID": "referenced_tweets.id", + "Referenced_Tweet_Author_ID": "referenced_tweets.id.author_id", +} + + +def get_backend_expansion(frontend_key: str) -> str: + result = EXPANSION_FRONTEND_TO_BACKEND_MAPPING.get(frontend_key) + if result is None: + raise KeyError(f"Invalid expansion key: {frontend_key}") + return result + + +# TweetReplySettings +REPLY_SETTINGS_FRONTEND_TO_BACKEND_MAPPING = { + "Mentioned_Users_Only": "mentionedUsers", + "Following_Users_Only": "following", + "All_Users": "all", +} + + +# TweetUserFields +def get_backend_reply_setting(frontend_key: str) -> str: + result = REPLY_SETTINGS_FRONTEND_TO_BACKEND_MAPPING.get(frontend_key) + if result is None: + raise KeyError(f"Invalid reply setting key: {frontend_key}") + return result + + +USER_FIELDS_FRONTEND_TO_BACKEND_MAPPING = { + "Account_Creation_Date": "created_at", + "User_Bio": "description", + "User_Entities": "entities", + "User_ID": "id", + "User_Location": "location", + "Latest_Tweet_ID": "most_recent_tweet_id", + "Display_Name": "name", + "Pinned_Tweet_ID": "pinned_tweet_id", + "Profile_Picture_URL": "profile_image_url", + "Is_Protected_Account": "protected", + "Account_Statistics": "public_metrics", + "Profile_URL": "url", + "Username": "username", + "Is_Verified": "verified", + "Verification_Type": "verified_type", + "Content_Withholding_Info": "withheld", +} + + +def get_backend_user_field(frontend_key: str) -> str: + result = USER_FIELDS_FRONTEND_TO_BACKEND_MAPPING.get(frontend_key) + if result is None: + raise KeyError(f"Invalid user field key: {frontend_key}") + return result + + +# TweetFields +FIELDS_FRONTEND_TO_BACKEND_MAPPING = { + "Tweet_Attachments": "attachments", + "Author_ID": "author_id", + "Context_Annotations": "context_annotations", + "Conversation_ID": "conversation_id", + "Creation_Time": "created_at", + "Edit_Controls": "edit_controls", + "Tweet_Entities": "entities", + "Geographic_Location": "geo", + "Tweet_ID": "id", + "Reply_To_User_ID": "in_reply_to_user_id", + "Language": "lang", + "Public_Metrics": "public_metrics", + "Sensitive_Content_Flag": "possibly_sensitive", + "Referenced_Tweets": "referenced_tweets", + "Reply_Settings": "reply_settings", + "Tweet_Source": "source", + "Tweet_Text": "text", + "Withheld_Content": "withheld", +} + + +def get_backend_field(frontend_key: str) -> str: + result = FIELDS_FRONTEND_TO_BACKEND_MAPPING.get(frontend_key) + if result is None: + raise KeyError(f"Invalid field key: {frontend_key}") + return result + + +# TweetPollFields +POLL_FIELDS_FRONTEND_TO_BACKEND_MAPPING = { + "Duration_Minutes": "duration_minutes", + "End_DateTime": "end_datetime", + "Poll_ID": "id", + "Poll_Options": "options", + "Voting_Status": "voting_status", +} + + +def get_backend_poll_field(frontend_key: str) -> str: + result = POLL_FIELDS_FRONTEND_TO_BACKEND_MAPPING.get(frontend_key) + if result is None: + raise KeyError(f"Invalid poll field key: {frontend_key}") + return result + + +PLACE_FIELDS_FRONTEND_TO_BACKEND_MAPPING = { + "Contained_Within_Places": "contained_within", + "Country": "country", + "Country_Code": "country_code", + "Full_Location_Name": "full_name", + "Geographic_Coordinates": "geo", + "Place_ID": "id", + "Place_Name": "name", + "Place_Type": "place_type", +} + + +def get_backend_place_field(frontend_key: str) -> str: + result = PLACE_FIELDS_FRONTEND_TO_BACKEND_MAPPING.get(frontend_key) + if result is None: + raise KeyError(f"Invalid place field key: {frontend_key}") + return result + + +# TweetMediaFields +MEDIA_FIELDS_FRONTEND_TO_BACKEND_MAPPING = { + "Duration_in_Milliseconds": "duration_ms", + "Height": "height", + "Media_Key": "media_key", + "Preview_Image_URL": "preview_image_url", + "Media_Type": "type", + "Media_URL": "url", + "Width": "width", + "Public_Metrics": "public_metrics", + "Non_Public_Metrics": "non_public_metrics", + "Organic_Metrics": "organic_metrics", + "Promoted_Metrics": "promoted_metrics", + "Alternative_Text": "alt_text", + "Media_Variants": "variants", +} + + +def get_backend_media_field(frontend_key: str) -> str: + result = MEDIA_FIELDS_FRONTEND_TO_BACKEND_MAPPING.get(frontend_key) + if result is None: + raise KeyError(f"Invalid media field key: {frontend_key}") + return result + + +# -------------- Spaces ----------------- + +# SpaceExpansions +EXPANSION_FRONTEND_TO_BACKEND_MAPPING_SPACE = { + "Invited_Users": "invited_user_ids", + "Speakers": "speaker_ids", + "Creator": "creator_id", + "Hosts": "host_ids", + "Topics": "topic_ids", +} + + +def get_backend_space_expansion(frontend_key: str) -> str: + result = EXPANSION_FRONTEND_TO_BACKEND_MAPPING_SPACE.get(frontend_key) + if result is None: + raise KeyError(f"Invalid expansion key: {frontend_key}") + return result + + +# SpaceFields +SPACE_FIELDS_FRONTEND_TO_BACKEND_MAPPING = { + "Space_ID": "id", + "Space_State": "state", + "Creation_Time": "created_at", + "End_Time": "ended_at", + "Host_User_IDs": "host_ids", + "Language": "lang", + "Is_Ticketed": "is_ticketed", + "Invited_User_IDs": "invited_user_ids", + "Participant_Count": "participant_count", + "Subscriber_Count": "subscriber_count", + "Scheduled_Start_Time": "scheduled_start", + "Speaker_User_IDs": "speaker_ids", + "Start_Time": "started_at", + "Space_Title": "title", + "Topic_IDs": "topic_ids", + "Last_Updated_Time": "updated_at", +} + + +def get_backend_space_field(frontend_key: str) -> str: + result = SPACE_FIELDS_FRONTEND_TO_BACKEND_MAPPING.get(frontend_key) + if result is None: + raise KeyError(f"Invalid space field key: {frontend_key}") + return result + + +# -------------- List Expansions ----------------- + +# ListExpansions +LIST_EXPANSION_FRONTEND_TO_BACKEND_MAPPING = {"List_Owner_ID": "owner_id"} + + +def get_backend_list_expansion(frontend_key: str) -> str: + result = LIST_EXPANSION_FRONTEND_TO_BACKEND_MAPPING.get(frontend_key) + if result is None: + raise KeyError(f"Invalid list expansion key: {frontend_key}") + return result + + +LIST_FIELDS_FRONTEND_TO_BACKEND_MAPPING = { + "List_ID": "id", + "List_Name": "name", + "Creation_Date": "created_at", + "Description": "description", + "Follower_Count": "follower_count", + "Member_Count": "member_count", + "Is_Private": "private", + "Owner_ID": "owner_id", +} + + +def get_backend_list_field(frontend_key: str) -> str: + result = LIST_FIELDS_FRONTEND_TO_BACKEND_MAPPING.get(frontend_key) + if result is None: + raise KeyError(f"Invalid list field key: {frontend_key}") + return result diff --git a/autogpt_platform/backend/backend/blocks/twitter/_serializer.py b/autogpt_platform/backend/backend/blocks/twitter/_serializer.py new file mode 100644 index 000000000000..906c52445686 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/_serializer.py @@ -0,0 +1,76 @@ +from typing import Any, Dict, List + + +class BaseSerializer: + @staticmethod + def _serialize_value(value: Any) -> Any: + """Helper method to serialize individual values""" + if hasattr(value, "data"): + return value.data + return value + + +class IncludesSerializer(BaseSerializer): + @classmethod + def serialize(cls, includes: Dict[str, Any]) -> Dict[str, Any]: + """Serializes the includes dictionary""" + if not includes: + return {} + + serialized_includes = {} + for key, value in includes.items(): + if isinstance(value, list): + serialized_includes[key] = [ + cls._serialize_value(item) for item in value + ] + else: + serialized_includes[key] = cls._serialize_value(value) + + return serialized_includes + + +class ResponseDataSerializer(BaseSerializer): + @classmethod + def serialize_dict(cls, item: Dict[str, Any]) -> Dict[str, Any]: + """Serializes a single dictionary item""" + serialized_item = {} + + if hasattr(item, "__dict__"): + items = item.__dict__.items() + else: + items = item.items() + + for key, value in items: + if isinstance(value, list): + serialized_item[key] = [ + cls._serialize_value(sub_item) for sub_item in value + ] + else: + serialized_item[key] = cls._serialize_value(value) + + return serialized_item + + @classmethod + def serialize_list(cls, data: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Serializes a list of dictionary items""" + return [cls.serialize_dict(item) for item in data] + + +class ResponseSerializer: + @classmethod + def serialize(cls, response) -> Dict[str, Any]: + """Main serializer that handles both data and includes""" + result = {"data": None, "included": {}} + + # Handle response.data + if response.data: + if isinstance(response.data, list): + result["data"] = ResponseDataSerializer.serialize_list(response.data) + else: + result["data"] = ResponseDataSerializer.serialize_dict(response.data) + + # Handle includes + if hasattr(response, "includes") and response.includes: + result["included"] = IncludesSerializer.serialize(response.includes) + + return result diff --git a/autogpt_platform/backend/backend/blocks/twitter/_types.py b/autogpt_platform/backend/backend/blocks/twitter/_types.py new file mode 100644 index 000000000000..2b404e4f560e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/_types.py @@ -0,0 +1,443 @@ +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel + +from backend.data.block import BlockSchema +from backend.data.model import SchemaField + +# -------------- Tweets ----------------- + + +class TweetReplySettingsFilter(BaseModel): + Mentioned_Users_Only: bool = False + Following_Users_Only: bool = False + All_Users: bool = False + + +class TweetUserFieldsFilter(BaseModel): + Account_Creation_Date: bool = False + User_Bio: bool = False + User_Entities: bool = False + User_ID: bool = False + User_Location: bool = False + Latest_Tweet_ID: bool = False + Display_Name: bool = False + Pinned_Tweet_ID: bool = False + Profile_Picture_URL: bool = False + Is_Protected_Account: bool = False + Account_Statistics: bool = False + Profile_URL: bool = False + Username: bool = False + Is_Verified: bool = False + Verification_Type: bool = False + Content_Withholding_Info: bool = False + + +class TweetFieldsFilter(BaseModel): + Tweet_Attachments: bool = False + Author_ID: bool = False + Context_Annotations: bool = False + Conversation_ID: bool = False + Creation_Time: bool = False + Edit_Controls: bool = False + Tweet_Entities: bool = False + Geographic_Location: bool = False + Tweet_ID: bool = False + Reply_To_User_ID: bool = False + Language: bool = False + Public_Metrics: bool = False + Sensitive_Content_Flag: bool = False + Referenced_Tweets: bool = False + Reply_Settings: bool = False + Tweet_Source: bool = False + Tweet_Text: bool = False + Withheld_Content: bool = False + + +class PersonalTweetFieldsFilter(BaseModel): + attachments: bool = False + author_id: bool = False + context_annotations: bool = False + conversation_id: bool = False + created_at: bool = False + edit_controls: bool = False + entities: bool = False + geo: bool = False + id: bool = False + in_reply_to_user_id: bool = False + lang: bool = False + non_public_metrics: bool = False + public_metrics: bool = False + organic_metrics: bool = False + promoted_metrics: bool = False + possibly_sensitive: bool = False + referenced_tweets: bool = False + reply_settings: bool = False + source: bool = False + text: bool = False + withheld: bool = False + + +class TweetPollFieldsFilter(BaseModel): + Duration_Minutes: bool = False + End_DateTime: bool = False + Poll_ID: bool = False + Poll_Options: bool = False + Voting_Status: bool = False + + +class TweetPlaceFieldsFilter(BaseModel): + Contained_Within_Places: bool = False + Country: bool = False + Country_Code: bool = False + Full_Location_Name: bool = False + Geographic_Coordinates: bool = False + Place_ID: bool = False + Place_Name: bool = False + Place_Type: bool = False + + +class TweetMediaFieldsFilter(BaseModel): + Duration_in_Milliseconds: bool = False + Height: bool = False + Media_Key: bool = False + Preview_Image_URL: bool = False + Media_Type: bool = False + Media_URL: bool = False + Width: bool = False + Public_Metrics: bool = False + Non_Public_Metrics: bool = False + Organic_Metrics: bool = False + Promoted_Metrics: bool = False + Alternative_Text: bool = False + Media_Variants: bool = False + + +class ExpansionFilter(BaseModel): + Poll_IDs: bool = False + Media_Keys: bool = False + Author_User_ID: bool = False + Edit_History_Tweet_IDs: bool = False + Mentioned_Usernames: bool = False + Place_ID: bool = False + Reply_To_User_ID: bool = False + Referenced_Tweet_ID: bool = False + Referenced_Tweet_Author_ID: bool = False + + +class TweetExcludesFilter(BaseModel): + retweets: bool = False + replies: bool = False + + +# -------------- Users ----------------- + + +class UserExpansionsFilter(BaseModel): + pinned_tweet_id: bool = False + + +# -------------- DM's' ----------------- + + +class DMEventFieldFilter(BaseModel): + id: bool = False + text: bool = False + event_type: bool = False + created_at: bool = False + dm_conversation_id: bool = False + sender_id: bool = False + participant_ids: bool = False + referenced_tweets: bool = False + attachments: bool = False + + +class DMEventTypeFilter(BaseModel): + MessageCreate: bool = False + ParticipantsJoin: bool = False + ParticipantsLeave: bool = False + + +class DMEventExpansionFilter(BaseModel): + attachments_media_keys: bool = False + referenced_tweets_id: bool = False + sender_id: bool = False + participant_ids: bool = False + + +class DMMediaFieldFilter(BaseModel): + duration_ms: bool = False + height: bool = False + media_key: bool = False + preview_image_url: bool = False + type: bool = False + url: bool = False + width: bool = False + public_metrics: bool = False + alt_text: bool = False + variants: bool = False + + +class DMTweetFieldFilter(BaseModel): + attachments: bool = False + author_id: bool = False + context_annotations: bool = False + conversation_id: bool = False + created_at: bool = False + edit_controls: bool = False + entities: bool = False + geo: bool = False + id: bool = False + in_reply_to_user_id: bool = False + lang: bool = False + public_metrics: bool = False + possibly_sensitive: bool = False + referenced_tweets: bool = False + reply_settings: bool = False + source: bool = False + text: bool = False + withheld: bool = False + + +# -------------- Spaces ----------------- + + +class SpaceExpansionsFilter(BaseModel): + Invited_Users: bool = False + Speakers: bool = False + Creator: bool = False + Hosts: bool = False + Topics: bool = False + + +class SpaceFieldsFilter(BaseModel): + Space_ID: bool = False + Space_State: bool = False + Creation_Time: bool = False + End_Time: bool = False + Host_User_IDs: bool = False + Language: bool = False + Is_Ticketed: bool = False + Invited_User_IDs: bool = False + Participant_Count: bool = False + Subscriber_Count: bool = False + Scheduled_Start_Time: bool = False + Speaker_User_IDs: bool = False + Start_Time: bool = False + Space_Title: bool = False + Topic_IDs: bool = False + Last_Updated_Time: bool = False + + +class SpaceStatesFilter(str, Enum): + live = "live" + scheduled = "scheduled" + all = "all" + + +# -------------- List Expansions ----------------- + + +class ListExpansionsFilter(BaseModel): + List_Owner_ID: bool = False + + +class ListFieldsFilter(BaseModel): + List_ID: bool = False + List_Name: bool = False + Creation_Date: bool = False + Description: bool = False + Follower_Count: bool = False + Member_Count: bool = False + Is_Private: bool = False + Owner_ID: bool = False + + +# --------- [Input Types] ------------- +class TweetExpansionInputs(BlockSchema): + + expansions: ExpansionFilter | None = SchemaField( + description="Choose what extra information you want to get with your tweets. For example:\n- Select 'Media_Keys' to get media details\n- Select 'Author_User_ID' to get user information\n- Select 'Place_ID' to get location details", + placeholder="Pick the extra information you want to see", + default=None, + advanced=True, + ) + + media_fields: TweetMediaFieldsFilter | None = SchemaField( + description="Select what media information you want to see (images, videos, etc). To use this, you must first select 'Media_Keys' in the expansions above.", + placeholder="Choose what media details you want to see", + default=None, + advanced=True, + ) + + place_fields: TweetPlaceFieldsFilter | None = SchemaField( + description="Select what location information you want to see (country, coordinates, etc). To use this, you must first select 'Place_ID' in the expansions above.", + placeholder="Choose what location details you want to see", + default=None, + advanced=True, + ) + + poll_fields: TweetPollFieldsFilter | None = SchemaField( + description="Select what poll information you want to see (options, voting status, etc). To use this, you must first select 'Poll_IDs' in the expansions above.", + placeholder="Choose what poll details you want to see", + default=None, + advanced=True, + ) + + tweet_fields: TweetFieldsFilter | None = SchemaField( + description="Select what tweet information you want to see. For referenced tweets (like retweets), select 'Referenced_Tweet_ID' in the expansions above.", + placeholder="Choose what tweet details you want to see", + default=None, + advanced=True, + ) + + user_fields: TweetUserFieldsFilter | None = SchemaField( + description="Select what user information you want to see. To use this, you must first select one of these in expansions above:\n- 'Author_User_ID' for tweet authors\n- 'Mentioned_Usernames' for mentioned users\n- 'Reply_To_User_ID' for users being replied to\n- 'Referenced_Tweet_Author_ID' for authors of referenced tweets", + placeholder="Choose what user details you want to see", + default=None, + advanced=True, + ) + + +class DMEventExpansionInputs(BlockSchema): + expansions: DMEventExpansionFilter | None = SchemaField( + description="Select expansions to include related data objects in the 'includes' section.", + placeholder="Enter expansions", + default=None, + advanced=True, + ) + + event_types: DMEventTypeFilter | None = SchemaField( + description="Select DM event types to include in the response.", + placeholder="Enter event types", + default=None, + advanced=True, + ) + + media_fields: DMMediaFieldFilter | None = SchemaField( + description="Select media fields to include in the response (requires expansions=attachments.media_keys).", + placeholder="Enter media fields", + default=None, + advanced=True, + ) + + tweet_fields: DMTweetFieldFilter | None = SchemaField( + description="Select tweet fields to include in the response (requires expansions=referenced_tweets.id).", + placeholder="Enter tweet fields", + default=None, + advanced=True, + ) + + user_fields: TweetUserFieldsFilter | None = SchemaField( + description="Select user fields to include in the response (requires expansions=sender_id or participant_ids).", + placeholder="Enter user fields", + default=None, + advanced=True, + ) + + +class UserExpansionInputs(BlockSchema): + expansions: UserExpansionsFilter | None = SchemaField( + description="Choose what extra information you want to get with user data. Currently only 'pinned_tweet_id' is available to see a user's pinned tweet.", + placeholder="Select extra user information to include", + default=None, + advanced=True, + ) + + tweet_fields: TweetFieldsFilter | None = SchemaField( + description="Select what tweet information you want to see in pinned tweets. This only works if you select 'pinned_tweet_id' in expansions above.", + placeholder="Choose what details to see in pinned tweets", + default=None, + advanced=True, + ) + + user_fields: TweetUserFieldsFilter | None = SchemaField( + description="Select what user information you want to see, like username, bio, profile picture, etc.", + placeholder="Choose what user details you want to see", + default=None, + advanced=True, + ) + + +class SpaceExpansionInputs(BlockSchema): + expansions: SpaceExpansionsFilter | None = SchemaField( + description="Choose additional information you want to get with your Twitter Spaces:\n- Select 'Invited_Users' to see who was invited\n- Select 'Speakers' to see who can speak\n- Select 'Creator' to get details about who made the Space\n- Select 'Hosts' to see who's hosting\n- Select 'Topics' to see Space topics", + placeholder="Pick what extra information you want to see about the Space", + default=None, + advanced=True, + ) + + space_fields: SpaceFieldsFilter | None = SchemaField( + description="Choose what Space details you want to see, such as:\n- Title\n- Start/End times\n- Number of participants\n- Language\n- State (live/scheduled)\n- And more", + placeholder="Choose what Space information you want to get", + default=SpaceFieldsFilter(Space_Title=True, Host_User_IDs=True), + advanced=True, + ) + + user_fields: TweetUserFieldsFilter | None = SchemaField( + description="Choose what user information you want to see. This works when you select any of these in expansions above:\n- 'Creator' for Space creator details\n- 'Hosts' for host information\n- 'Speakers' for speaker details\n- 'Invited_Users' for invited user information", + placeholder="Pick what details you want to see about the users", + default=None, + advanced=True, + ) + + +class ListExpansionInputs(BlockSchema): + expansions: ListExpansionsFilter | None = SchemaField( + description="Choose what extra information you want to get with your Twitter Lists:\n- Select 'List_Owner_ID' to get details about who owns the list\n\nThis will let you see more details about the list owner when you also select user fields below.", + placeholder="Pick what extra list information you want to see", + default=ListExpansionsFilter(List_Owner_ID=True), + advanced=True, + ) + + user_fields: TweetUserFieldsFilter | None = SchemaField( + description="Choose what information you want to see about list owners. This only works when you select 'List_Owner_ID' in expansions above.\n\nYou can see things like:\n- Their username\n- Profile picture\n- Account details\n- And more", + placeholder="Select what details you want to see about list owners", + default=TweetUserFieldsFilter(User_ID=True, Username=True), + advanced=True, + ) + + list_fields: ListFieldsFilter | None = SchemaField( + description="Choose what information you want to see about the Twitter Lists themselves, such as:\n- List name\n- Description\n- Number of followers\n- Number of members\n- Whether it's private\n- Creation date\n- And more", + placeholder="Pick what list details you want to see", + default=ListFieldsFilter(Owner_ID=True), + advanced=True, + ) + + +class TweetTimeWindowInputs(BlockSchema): + start_time: datetime | None = SchemaField( + description="Start time in YYYY-MM-DDTHH:mm:ssZ format", + placeholder="Enter start time", + default=None, + advanced=False, + ) + + end_time: datetime | None = SchemaField( + description="End time in YYYY-MM-DDTHH:mm:ssZ format", + placeholder="Enter end time", + default=None, + advanced=False, + ) + + since_id: str | None = SchemaField( + description="Returns results with Tweet ID greater than this (more recent than), we give priority to since_id over start_time", + placeholder="Enter since ID", + default=None, + advanced=True, + ) + + until_id: str | None = SchemaField( + description="Returns results with Tweet ID less than this (that is, older than), and used with since_id", + placeholder="Enter until ID", + default=None, + advanced=True, + ) + + sort_order: str | None = SchemaField( + description="Order of returned tweets (recency or relevancy)", + placeholder="Enter sort order", + default=None, + advanced=True, + ) diff --git a/autogpt_platform/backend/backend/blocks/twitter/direct_message/direct_message_lookup.py b/autogpt_platform/backend/backend/blocks/twitter/direct_message/direct_message_lookup.py new file mode 100644 index 000000000000..99c5bcab791b --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/direct_message/direct_message_lookup.py @@ -0,0 +1,201 @@ +# Todo : Add new Type support, and disable block if it's Oauth is not configured + +# from typing import cast +# import tweepy +# from tweepy.client import Response + +# from backend.blocks.twitter._serializer import IncludesSerializer, ResponseDataSerializer +# from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +# from backend.data.model import SchemaField +# from backend.blocks.twitter._builders import DMExpansionsBuilder +# from backend.blocks.twitter._types import DMEventExpansion, DMEventExpansionInputs, DMEventType, DMMediaField, DMTweetField, TweetUserFields +# from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +# from backend.blocks.twitter._auth import ( +# TEST_CREDENTIALS, +# TEST_CREDENTIALS_INPUT, +# TwitterCredentials, +# TwitterCredentialsField, +# TwitterCredentialsInput, +# ) + +# Require Pro or Enterprise plan [Manual Testing Required] +# class TwitterGetDMEventsBlock(Block): +# """ +# Gets a list of Direct Message events for the authenticated user +# """ + +# class Input(DMEventExpansionInputs): +# credentials: TwitterCredentialsInput = TwitterCredentialsField( +# ["dm.read", "offline.access", "user.read", "tweet.read"] +# ) + +# dm_conversation_id: str = SchemaField( +# description="The ID of the Direct Message conversation", +# placeholder="Enter conversation ID", +# required=True +# ) + +# max_results: int = SchemaField( +# description="Maximum number of results to return (1-100)", +# placeholder="Enter max results", +# advanced=True, +# default=10, +# ) + +# pagination_token: str = SchemaField( +# description="Token for pagination", +# placeholder="Enter pagination token", +# advanced=True, +# default="" +# ) + +# class Output(BlockSchema): +# # Common outputs +# event_ids: list[str] = SchemaField(description="DM Event IDs") +# event_texts: list[str] = SchemaField(description="DM Event text contents") +# event_types: list[str] = SchemaField(description="Types of DM events") +# next_token: str = SchemaField(description="Token for next page of results") + +# # Complete outputs +# data: list[dict] = SchemaField(description="Complete DM events data") +# included: dict = SchemaField(description="Additional data requested via expansions") +# meta: dict = SchemaField(description="Metadata about the response") +# error: str = SchemaField(description="Error message if request failed") + +# def __init__(self): +# super().__init__( +# id="dc37a6d4-a62e-11ef-a3a5-03061375737b", +# description="This block retrieves Direct Message events for the authenticated user.", +# categories={BlockCategory.SOCIAL}, +# input_schema=TwitterGetDMEventsBlock.Input, +# output_schema=TwitterGetDMEventsBlock.Output, +# test_input={ +# "dm_conversation_id": "1234567890", +# "max_results": 10, +# "credentials": TEST_CREDENTIALS_INPUT, +# "expansions": [], +# "event_types": [], +# "media_fields": [], +# "tweet_fields": [], +# "user_fields": [] +# }, +# test_credentials=TEST_CREDENTIALS, +# test_output=[ +# ("event_ids", ["1346889436626259968"]), +# ("event_texts", ["Hello just you..."]), +# ("event_types", ["MessageCreate"]), +# ("next_token", None), +# ("data", [{"id": "1346889436626259968", "text": "Hello just you...", "event_type": "MessageCreate"}]), +# ("included", {}), +# ("meta", {}), +# ("error", "") +# ], +# test_mock={ +# "get_dm_events": lambda *args, **kwargs: ( +# [{"id": "1346889436626259968", "text": "Hello just you...", "event_type": "MessageCreate"}], +# {}, +# {}, +# ["1346889436626259968"], +# ["Hello just you..."], +# ["MessageCreate"], +# None +# ) +# } +# ) + +# @staticmethod +# def get_dm_events( +# credentials: TwitterCredentials, +# dm_conversation_id: str, +# max_results: int, +# pagination_token: str, +# expansions: list[DMEventExpansion], +# event_types: list[DMEventType], +# media_fields: list[DMMediaField], +# tweet_fields: list[DMTweetField], +# user_fields: list[TweetUserFields] +# ): +# try: +# client = tweepy.Client( +# bearer_token=credentials.access_token.get_secret_value() +# ) + +# params = { +# "dm_conversation_id": dm_conversation_id, +# "max_results": max_results, +# "pagination_token": None if pagination_token == "" else pagination_token, +# "user_auth": False +# } + +# params = (DMExpansionsBuilder(params) +# .add_expansions(expansions) +# .add_event_types(event_types) +# .add_media_fields(media_fields) +# .add_tweet_fields(tweet_fields) +# .add_user_fields(user_fields) +# .build()) + +# response = cast(Response, client.get_direct_message_events(**params)) + +# meta = {} +# event_ids = [] +# event_texts = [] +# event_types = [] +# next_token = None + +# if response.meta: +# meta = response.meta +# next_token = meta.get("next_token") + +# included = IncludesSerializer.serialize(response.includes) +# data = ResponseDataSerializer.serialize_list(response.data) + +# if response.data: +# event_ids = [str(item.id) for item in response.data] +# event_texts = [item.text if hasattr(item, "text") else None for item in response.data] +# event_types = [item.event_type for item in response.data] + +# return data, included, meta, event_ids, event_texts, event_types, next_token + +# raise Exception("No DM events found") + +# except tweepy.TweepyException: +# raise + +# async def run( +# self, +# input_data: Input, +# *, +# credentials: TwitterCredentials, +# **kwargs, +# ) -> BlockOutput: +# try: +# event_data, included, meta, event_ids, event_texts, event_types, next_token = self.get_dm_events( +# credentials, +# input_data.dm_conversation_id, +# input_data.max_results, +# input_data.pagination_token, +# input_data.expansions, +# input_data.event_types, +# input_data.media_fields, +# input_data.tweet_fields, +# input_data.user_fields +# ) + +# if event_ids: +# yield "event_ids", event_ids +# if event_texts: +# yield "event_texts", event_texts +# if event_types: +# yield "event_types", event_types +# if next_token: +# yield "next_token", next_token +# if event_data: +# yield "data", event_data +# if included: +# yield "included", included +# if meta: +# yield "meta", meta + +# except Exception as e: +# yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/direct_message/manage_direct_message.py b/autogpt_platform/backend/backend/blocks/twitter/direct_message/manage_direct_message.py new file mode 100644 index 000000000000..19fdb2819fa9 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/direct_message/manage_direct_message.py @@ -0,0 +1,260 @@ +# Todo : Add new Type support, and disable block if it's Oauth is not configured + +# from typing import cast + +# import tweepy +# from tweepy.client import Response + +# from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +# from backend.data.model import SchemaField +# from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +# from backend.blocks.twitter._auth import ( +# TEST_CREDENTIALS, +# TEST_CREDENTIALS_INPUT, +# TwitterCredentials, +# TwitterCredentialsField, +# TwitterCredentialsInput, +# ) + +# Pro and Enterprise plan [Manual Testing Required] +# class TwitterSendDirectMessageBlock(Block): +# """ +# Sends a direct message to a Twitter user +# """ + +# class Input(BlockSchema): +# credentials: TwitterCredentialsInput = TwitterCredentialsField( +# ["offline.access", "direct_messages.write"] +# ) + +# participant_id: str = SchemaField( +# description="The User ID of the account to send DM to", +# placeholder="Enter recipient user ID", +# default="", +# advanced=False +# ) + +# dm_conversation_id: str = SchemaField( +# description="The conversation ID to send message to", +# placeholder="Enter conversation ID", +# default="", +# advanced=False +# ) + +# text: str = SchemaField( +# description="Text of the Direct Message (up to 10,000 characters)", +# placeholder="Enter message text", +# default="", +# advanced=False +# ) + +# media_id: str = SchemaField( +# description="Media ID to attach to the message", +# placeholder="Enter media ID", +# default="" +# ) + +# class Output(BlockSchema): +# dm_event_id: str = SchemaField(description="ID of the sent direct message") +# dm_conversation_id_: str = SchemaField(description="ID of the conversation") +# error: str = SchemaField(description="Error message if sending failed") + +# def __init__(self): +# super().__init__( +# id="f32f2786-a62e-11ef-a93d-a3ef199dde7f", +# description="This block sends a direct message to a specified Twitter user.", +# categories={BlockCategory.SOCIAL}, +# input_schema=TwitterSendDirectMessageBlock.Input, +# output_schema=TwitterSendDirectMessageBlock.Output, +# test_input={ +# "participant_id": "783214", +# "dm_conversation_id": "", +# "text": "Hello from Twitter API", +# "media_id": "", +# "credentials": TEST_CREDENTIALS_INPUT +# }, +# test_credentials=TEST_CREDENTIALS, +# test_output=[ +# ("dm_event_id", "0987654321"), +# ("dm_conversation_id_", "1234567890"), +# ("error", "") +# ], +# test_mock={ +# "send_direct_message": lambda *args, **kwargs: ( +# "0987654321", +# "1234567890" +# ) +# }, +# ) + +# @staticmethod +# def send_direct_message( +# credentials: TwitterCredentials, +# participant_id: str, +# dm_conversation_id: str, +# text: str, +# media_id: str +# ): +# try: +# client = tweepy.Client( +# bearer_token=credentials.access_token.get_secret_value() +# ) + +# response = cast( +# Response, +# client.create_direct_message( +# participant_id=None if participant_id == "" else participant_id, +# dm_conversation_id=None if dm_conversation_id == "" else dm_conversation_id, +# text=None if text == "" else text, +# media_id=None if media_id == "" else media_id, +# user_auth=False +# ) +# ) + +# if not response.data: +# raise Exception("Failed to send direct message") + +# return response.data["dm_event_id"], response.data["dm_conversation_id"] + +# except tweepy.TweepyException: +# raise +# except Exception as e: +# print(f"Unexpected error: {str(e)}") +# raise + +# async def run( +# self, +# input_data: Input, +# *, +# credentials: TwitterCredentials, +# **kwargs, +# ) -> BlockOutput: +# try: +# dm_event_id, dm_conversation_id = self.send_direct_message( +# credentials, +# input_data.participant_id, +# input_data.dm_conversation_id, +# input_data.text, +# input_data.media_id +# ) +# yield "dm_event_id", dm_event_id +# yield "dm_conversation_id", dm_conversation_id + +# except Exception as e: +# yield "error", handle_tweepy_exception(e) + +# class TwitterCreateDMConversationBlock(Block): +# """ +# Creates a new group direct message conversation on Twitter +# """ + +# class Input(BlockSchema): +# credentials: TwitterCredentialsInput = TwitterCredentialsField( +# ["offline.access", "dm.write","dm.read","tweet.read","user.read"] +# ) + +# participant_ids: list[str] = SchemaField( +# description="Array of User IDs to create conversation with (max 50)", +# placeholder="Enter participant user IDs", +# default_factory=list, +# advanced=False +# ) + +# text: str = SchemaField( +# description="Text of the Direct Message (up to 10,000 characters)", +# placeholder="Enter message text", +# default="", +# advanced=False +# ) + +# media_id: str = SchemaField( +# description="Media ID to attach to the message", +# placeholder="Enter media ID", +# default="", +# advanced=False +# ) + +# class Output(BlockSchema): +# dm_event_id: str = SchemaField(description="ID of the sent direct message") +# dm_conversation_id: str = SchemaField(description="ID of the conversation") +# error: str = SchemaField(description="Error message if sending failed") + +# def __init__(self): +# super().__init__( +# id="ec11cabc-a62e-11ef-8c0e-3fe37ba2ec92", +# description="This block creates a new group DM conversation with specified Twitter users.", +# categories={BlockCategory.SOCIAL}, +# input_schema=TwitterCreateDMConversationBlock.Input, +# output_schema=TwitterCreateDMConversationBlock.Output, +# test_input={ +# "participant_ids": ["783214", "2244994945"], +# "text": "Hello from Twitter API", +# "media_id": "", +# "credentials": TEST_CREDENTIALS_INPUT +# }, +# test_credentials=TEST_CREDENTIALS, +# test_output=[ +# ("dm_event_id", "0987654321"), +# ("dm_conversation_id", "1234567890"), +# ("error", "") +# ], +# test_mock={ +# "create_dm_conversation": lambda *args, **kwargs: ( +# "0987654321", +# "1234567890" +# ) +# }, +# ) + +# @staticmethod +# def create_dm_conversation( +# credentials: TwitterCredentials, +# participant_ids: list[str], +# text: str, +# media_id: str +# ): +# try: +# client = tweepy.Client( +# bearer_token=credentials.access_token.get_secret_value() +# ) + +# response = cast( +# Response, +# client.create_direct_message_conversation( +# participant_ids=participant_ids, +# text=None if text == "" else text, +# media_id=None if media_id == "" else media_id, +# user_auth=False +# ) +# ) + +# if not response.data: +# raise Exception("Failed to create DM conversation") + +# return response.data["dm_event_id"], response.data["dm_conversation_id"] + +# except tweepy.TweepyException: +# raise +# except Exception as e: +# print(f"Unexpected error: {str(e)}") +# raise + +# async def run( +# self, +# input_data: Input, +# *, +# credentials: TwitterCredentials, +# **kwargs, +# ) -> BlockOutput: +# try: +# dm_event_id, dm_conversation_id = self.create_dm_conversation( +# credentials, +# input_data.participant_ids, +# input_data.text, +# input_data.media_id +# ) +# yield "dm_event_id", dm_event_id +# yield "dm_conversation_id", dm_conversation_id + +# except Exception as e: +# yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/lists/list_follows.py b/autogpt_platform/backend/backend/blocks/twitter/lists/list_follows.py new file mode 100644 index 000000000000..62c6c05f0c22 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/lists/list_follows.py @@ -0,0 +1,473 @@ +# from typing import cast +import tweepy + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) + +# from backend.blocks.twitter._builders import UserExpansionsBuilder +# from backend.blocks.twitter._types import TweetFields, TweetUserFields, UserExpansionInputs, UserExpansions +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + +# from tweepy.client import Response + + +class TwitterUnfollowListBlock(Block): + """ + Unfollows a Twitter list for the authenticated user + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["follows.write", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List to unfollow", + placeholder="Enter list ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the unfollow was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="1f43310a-a62f-11ef-8276-2b06a1bbae1a", + description="This block unfollows a specified Twitter list for the authenticated user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterUnfollowListBlock.Input, + output_schema=TwitterUnfollowListBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={"list_id": "123456789", "credentials": TEST_CREDENTIALS_INPUT}, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"unfollow_list": lambda *args, **kwargs: True}, + ) + + @staticmethod + def unfollow_list(credentials: TwitterCredentials, list_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.unfollow_list(list_id=list_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.unfollow_list(credentials, input_data.list_id) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterFollowListBlock(Block): + """ + Follows a Twitter list for the authenticated user + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "list.write", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List to follow", + placeholder="Enter list ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the follow was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="03d8acf6-a62f-11ef-b17f-b72b04a09e79", + description="This block follows a specified Twitter list for the authenticated user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterFollowListBlock.Input, + output_schema=TwitterFollowListBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={"list_id": "123456789", "credentials": TEST_CREDENTIALS_INPUT}, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"follow_list": lambda *args, **kwargs: True}, + ) + + @staticmethod + def follow_list(credentials: TwitterCredentials, list_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.follow_list(list_id=list_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.follow_list(credentials, input_data.list_id) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +# Enterprise Level [Need to do Manual testing], There is a high possibility that we might get error in this +# Needs Type Input in this + +# class TwitterListGetFollowersBlock(Block): +# """ +# Gets followers of a specified Twitter list +# """ + +# class Input(UserExpansionInputs): +# credentials: TwitterCredentialsInput = TwitterCredentialsField( +# ["tweet.read","users.read", "list.read", "offline.access"] +# ) + +# list_id: str = SchemaField( +# description="The ID of the List to get followers for", +# placeholder="Enter list ID", +# required=True +# ) + +# max_results: int = SchemaField( +# description="Max number of results per page (1-100)", +# placeholder="Enter max results", +# default=10, +# advanced=True, +# ) + +# pagination_token: str = SchemaField( +# description="Token for pagination", +# placeholder="Enter pagination token", +# default="", +# advanced=True, +# ) + +# class Output(BlockSchema): +# user_ids: list[str] = SchemaField(description="List of user IDs of followers") +# usernames: list[str] = SchemaField(description="List of usernames of followers") +# next_token: str = SchemaField(description="Token for next page of results") +# data: list[dict] = SchemaField(description="Complete follower data") +# included: dict = SchemaField(description="Additional data requested via expansions") +# meta: dict = SchemaField(description="Metadata about the response") +# error: str = SchemaField(description="Error message if the request failed") + +# def __init__(self): +# super().__init__( +# id="16b289b4-a62f-11ef-95d4-bb29b849eb99", +# description="This block retrieves followers of a specified Twitter list.", +# categories={BlockCategory.SOCIAL}, +# input_schema=TwitterListGetFollowersBlock.Input, +# output_schema=TwitterListGetFollowersBlock.Output, +# test_input={ +# "list_id": "123456789", +# "max_results": 10, +# "pagination_token": None, +# "credentials": TEST_CREDENTIALS_INPUT, +# "expansions": [], +# "tweet_fields": [], +# "user_fields": [] +# }, +# test_credentials=TEST_CREDENTIALS, +# test_output=[ +# ("user_ids", ["2244994945"]), +# ("usernames", ["testuser"]), +# ("next_token", None), +# ("data", {"followers": [{"id": "2244994945", "username": "testuser"}]}), +# ("included", {}), +# ("meta", {}), +# ("error", "") +# ], +# test_mock={ +# "get_list_followers": lambda *args, **kwargs: ({ +# "followers": [{"id": "2244994945", "username": "testuser"}] +# }, {}, {}, ["2244994945"], ["testuser"], None) +# } +# ) + +# @staticmethod +# def get_list_followers( +# credentials: TwitterCredentials, +# list_id: str, +# max_results: int, +# pagination_token: str, +# expansions: list[UserExpansions], +# tweet_fields: list[TweetFields], +# user_fields: list[TweetUserFields] +# ): +# try: +# client = tweepy.Client( +# bearer_token=credentials.access_token.get_secret_value(), +# ) + +# params = { +# "id": list_id, +# "max_results": max_results, +# "pagination_token": None if pagination_token == "" else pagination_token, +# "user_auth": False +# } + +# params = (UserExpansionsBuilder(params) +# .add_expansions(expansions) +# .add_tweet_fields(tweet_fields) +# .add_user_fields(user_fields) +# .build()) + +# response = cast( +# Response, +# client.get_list_followers(**params) +# ) + +# meta = {} +# user_ids = [] +# usernames = [] +# next_token = None + +# if response.meta: +# meta = response.meta +# next_token = meta.get("next_token") + +# included = IncludesSerializer.serialize(response.includes) +# data = ResponseDataSerializer.serialize_list(response.data) + +# if response.data: +# user_ids = [str(item.id) for item in response.data] +# usernames = [item.username for item in response.data] + +# return data, included, meta, user_ids, usernames, next_token + +# raise Exception("No followers found") + +# except tweepy.TweepyException: +# raise + +# async def run( +# self, +# input_data: Input, +# *, +# credentials: TwitterCredentials, +# **kwargs, +# ) -> BlockOutput: +# try: +# followers_data, included, meta, user_ids, usernames, next_token = self.get_list_followers( +# credentials, +# input_data.list_id, +# input_data.max_results, +# input_data.pagination_token, +# input_data.expansions, +# input_data.tweet_fields, +# input_data.user_fields +# ) + +# if user_ids: +# yield "user_ids", user_ids +# if usernames: +# yield "usernames", usernames +# if next_token: +# yield "next_token", next_token +# if followers_data: +# yield "data", followers_data +# if included: +# yield "included", included +# if meta: +# yield "meta", meta + +# except Exception as e: +# yield "error", handle_tweepy_exception(e) + +# class TwitterGetFollowedListsBlock(Block): +# """ +# Gets lists followed by a specified Twitter user +# """ + +# class Input(UserExpansionInputs): +# credentials: TwitterCredentialsInput = TwitterCredentialsField( +# ["follows.read", "users.read", "list.read", "offline.access"] +# ) + +# user_id: str = SchemaField( +# description="The user ID whose followed Lists to retrieve", +# placeholder="Enter user ID", +# required=True +# ) + +# max_results: int = SchemaField( +# description="Max number of results per page (1-100)", +# placeholder="Enter max results", +# default=10, +# advanced=True, +# ) + +# pagination_token: str = SchemaField( +# description="Token for pagination", +# placeholder="Enter pagination token", +# default="", +# advanced=True, +# ) + +# class Output(BlockSchema): +# list_ids: list[str] = SchemaField(description="List of list IDs") +# list_names: list[str] = SchemaField(description="List of list names") +# data: list[dict] = SchemaField(description="Complete list data") +# includes: dict = SchemaField(description="Additional data requested via expansions") +# meta: dict = SchemaField(description="Metadata about the response") +# next_token: str = SchemaField(description="Token for next page of results") +# error: str = SchemaField(description="Error message if the request failed") + +# def __init__(self): +# super().__init__( +# id="0e18bbfc-a62f-11ef-94fa-1f1e174b809e", +# description="This block retrieves all Lists a specified user follows.", +# categories={BlockCategory.SOCIAL}, +# input_schema=TwitterGetFollowedListsBlock.Input, +# output_schema=TwitterGetFollowedListsBlock.Output, +# test_input={ +# "user_id": "123456789", +# "max_results": 10, +# "pagination_token": None, +# "credentials": TEST_CREDENTIALS_INPUT, +# "expansions": [], +# "tweet_fields": [], +# "user_fields": [] +# }, +# test_credentials=TEST_CREDENTIALS, +# test_output=[ +# ("list_ids", ["12345"]), +# ("list_names", ["Test List"]), +# ("data", {"followed_lists": [{"id": "12345", "name": "Test List"}]}), +# ("includes", {}), +# ("meta", {}), +# ("next_token", None), +# ("error", "") +# ], +# test_mock={ +# "get_followed_lists": lambda *args, **kwargs: ({ +# "followed_lists": [{"id": "12345", "name": "Test List"}] +# }, {}, {}, ["12345"], ["Test List"], None) +# } +# ) + +# @staticmethod +# def get_followed_lists( +# credentials: TwitterCredentials, +# user_id: str, +# max_results: int, +# pagination_token: str, +# expansions: list[UserExpansions], +# tweet_fields: list[TweetFields], +# user_fields: list[TweetUserFields] +# ): +# try: +# client = tweepy.Client( +# bearer_token=credentials.access_token.get_secret_value(), +# ) + +# params = { +# "id": user_id, +# "max_results": max_results, +# "pagination_token": None if pagination_token == "" else pagination_token, +# "user_auth": False +# } + +# params = (UserExpansionsBuilder(params) +# .add_expansions(expansions) +# .add_tweet_fields(tweet_fields) +# .add_user_fields(user_fields) +# .build()) + +# response = cast( +# Response, +# client.get_followed_lists(**params) +# ) + +# meta = {} +# list_ids = [] +# list_names = [] +# next_token = None + +# if response.meta: +# meta = response.meta +# next_token = meta.get("next_token") + +# included = IncludesSerializer.serialize(response.includes) +# data = ResponseDataSerializer.serialize_list(response.data) + +# if response.data: +# list_ids = [str(item.id) for item in response.data] +# list_names = [item.name for item in response.data] + +# return data, included, meta, list_ids, list_names, next_token + +# raise Exception("No followed lists found") + +# except tweepy.TweepyException: +# raise + +# async def run( +# self, +# input_data: Input, +# *, +# credentials: TwitterCredentials, +# **kwargs, +# ) -> BlockOutput: +# try: +# lists_data, included, meta, list_ids, list_names, next_token = self.get_followed_lists( +# credentials, +# input_data.user_id, +# input_data.max_results, +# input_data.pagination_token, +# input_data.expansions, +# input_data.tweet_fields, +# input_data.user_fields +# ) + +# if list_ids: +# yield "list_ids", list_ids +# if list_names: +# yield "list_names", list_names +# if next_token: +# yield "next_token", next_token +# if lists_data: +# yield "data", lists_data +# if included: +# yield "includes", included +# if meta: +# yield "meta", meta + +# except Exception as e: +# yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/lists/list_lookup.py b/autogpt_platform/backend/backend/blocks/twitter/lists/list_lookup.py new file mode 100644 index 000000000000..6dbaf2b23d8e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/lists/list_lookup.py @@ -0,0 +1,349 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import ListExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ListExpansionInputs, + ListExpansionsFilter, + ListFieldsFilter, + TweetUserFieldsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterGetListBlock(Block): + """ + Gets information about a Twitter List specified by ID + """ + + class Input(ListExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List to lookup", + placeholder="Enter list ID", + ) + + class Output(BlockSchema): + # Common outputs + id: str = SchemaField(description="ID of the Twitter List") + name: str = SchemaField(description="Name of the Twitter List") + owner_id: str = SchemaField(description="ID of the List owner") + owner_username: str = SchemaField(description="Username of the List owner") + + # Complete outputs + data: dict = SchemaField(description="Complete list data") + included: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Metadata about the response") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="34ebc80a-a62f-11ef-9c2a-3fcab6c07079", + description="This block retrieves information about a specified Twitter List.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetListBlock.Input, + output_schema=TwitterGetListBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "list_id": "84839422", + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "list_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", "84839422"), + ("name", "Official Twitter Accounts"), + ("owner_id", "2244994945"), + ("owner_username", "TwitterAPI"), + ("data", {"id": "84839422", "name": "Official Twitter Accounts"}), + ], + test_mock={ + "get_list": lambda *args, **kwargs: ( + {"id": "84839422", "name": "Official Twitter Accounts"}, + {}, + {}, + "2244994945", + "TwitterAPI", + ) + }, + ) + + @staticmethod + def get_list( + credentials: TwitterCredentials, + list_id: str, + expansions: ListExpansionsFilter | None, + user_fields: TweetUserFieldsFilter | None, + list_fields: ListFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = {"id": list_id, "user_auth": False} + + params = ( + ListExpansionsBuilder(params) + .add_expansions(expansions) + .add_user_fields(user_fields) + .add_list_fields(list_fields) + .build() + ) + + response = cast(Response, client.get_list(**params)) + + meta = {} + owner_id = "" + owner_username = "" + included = {} + + if response.includes: + included = IncludesSerializer.serialize(response.includes) + + if "users" in included: + owner_id = str(included["users"][0]["id"]) + owner_username = included["users"][0]["username"] + + if response.meta: + meta = response.meta + + if response.data: + data_dict = ResponseDataSerializer.serialize_dict(response.data) + return data_dict, included, meta, owner_id, owner_username + + raise Exception("List not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + list_data, included, meta, owner_id, owner_username = self.get_list( + credentials, + input_data.list_id, + input_data.expansions, + input_data.user_fields, + input_data.list_fields, + ) + + yield "id", str(list_data["id"]) + yield "name", list_data["name"] + if owner_id: + yield "owner_id", owner_id + if owner_username: + yield "owner_username", owner_username + yield "data", {"id": list_data["id"], "name": list_data["name"]} + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetOwnedListsBlock(Block): + """ + Gets all Lists owned by the specified user + """ + + class Input(ListExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "list.read", "offline.access"] + ) + + user_id: str = SchemaField( + description="The user ID whose owned Lists to retrieve", + placeholder="Enter user ID", + ) + + max_results: int | None = SchemaField( + description="Maximum number of results per page (1-100)", + placeholder="Enter max results (default 100)", + advanced=True, + default=10, + ) + + pagination_token: str | None = SchemaField( + description="Token for pagination", + placeholder="Enter pagination token", + advanced=True, + default="", + ) + + class Output(BlockSchema): + # Common outputs + list_ids: list[str] = SchemaField(description="List ids of the owned lists") + list_names: list[str] = SchemaField(description="List names of the owned lists") + next_token: str = SchemaField(description="Token for next page of results") + + # Complete outputs + data: list[dict] = SchemaField(description="Complete owned lists data") + included: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Metadata about the response") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="2b6bdb26-a62f-11ef-a9ce-ff89c2568726", + description="This block retrieves all Lists owned by a specified Twitter user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetOwnedListsBlock.Input, + output_schema=TwitterGetOwnedListsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "user_id": "2244994945", + "max_results": 10, + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "list_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("list_ids", ["84839422"]), + ("list_names", ["Official Twitter Accounts"]), + ("data", [{"id": "84839422", "name": "Official Twitter Accounts"}]), + ], + test_mock={ + "get_owned_lists": lambda *args, **kwargs: ( + [{"id": "84839422", "name": "Official Twitter Accounts"}], + {}, + {}, + ["84839422"], + ["Official Twitter Accounts"], + None, + ) + }, + ) + + @staticmethod + def get_owned_lists( + credentials: TwitterCredentials, + user_id: str, + max_results: int | None, + pagination_token: str | None, + expansions: ListExpansionsFilter | None, + user_fields: TweetUserFieldsFilter | None, + list_fields: ListFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": user_id, + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + ListExpansionsBuilder(params) + .add_expansions(expansions) + .add_user_fields(user_fields) + .add_list_fields(list_fields) + .build() + ) + + response = cast(Response, client.get_owned_lists(**params)) + + meta = {} + included = {} + list_ids = [] + list_names = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + if response.includes: + included = IncludesSerializer.serialize(response.includes) + + if response.data: + data = ResponseDataSerializer.serialize_list(response.data) + list_ids = [ + str(item.id) for item in response.data if hasattr(item, "id") + ] + list_names = [ + item.name for item in response.data if hasattr(item, "name") + ] + + return data, included, meta, list_ids, list_names, next_token + + raise Exception("User have no owned list") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + list_data, included, meta, list_ids, list_names, next_token = ( + self.get_owned_lists( + credentials, + input_data.user_id, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.user_fields, + input_data.list_fields, + ) + ) + + if list_ids: + yield "list_ids", list_ids + if list_names: + yield "list_names", list_names + if next_token: + yield "next_token", next_token + if list_data: + yield "data", list_data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/lists/list_members.py b/autogpt_platform/backend/backend/blocks/twitter/lists/list_members.py new file mode 100644 index 000000000000..9bcd8f15a208 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/lists/list_members.py @@ -0,0 +1,526 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import ( + ListExpansionsBuilder, + UserExpansionsBuilder, +) +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ListExpansionInputs, + ListExpansionsFilter, + ListFieldsFilter, + TweetFieldsFilter, + TweetUserFieldsFilter, + UserExpansionInputs, + UserExpansionsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterRemoveListMemberBlock(Block): + """ + Removes a member from a Twitter List that the authenticated user owns + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["list.write", "users.read", "tweet.read", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List to remove the member from", + placeholder="Enter list ID", + ) + + user_id: str = SchemaField( + description="The ID of the user to remove from the List", + placeholder="Enter user ID to remove", + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the member was successfully removed" + ) + error: str = SchemaField(description="Error message if the removal failed") + + def __init__(self): + super().__init__( + id="5a3d1320-a62f-11ef-b7ce-a79e7656bcb0", + description="This block removes a specified user from a Twitter List owned by the authenticated user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterRemoveListMemberBlock.Input, + output_schema=TwitterRemoveListMemberBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "list_id": "123456789", + "user_id": "987654321", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"remove_list_member": lambda *args, **kwargs: True}, + ) + + @staticmethod + def remove_list_member(credentials: TwitterCredentials, list_id: str, user_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + client.remove_list_member(id=list_id, user_id=user_id, user_auth=False) + return True + except tweepy.TweepyException: + raise + except Exception: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.remove_list_member( + credentials, input_data.list_id, input_data.user_id + ) + yield "success", success + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterAddListMemberBlock(Block): + """ + Adds a member to a Twitter List that the authenticated user owns + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["list.write", "users.read", "tweet.read", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List to add the member to", + placeholder="Enter list ID", + ) + + user_id: str = SchemaField( + description="The ID of the user to add to the List", + placeholder="Enter user ID to add", + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the member was successfully added" + ) + error: str = SchemaField(description="Error message if the addition failed") + + def __init__(self): + super().__init__( + id="3ee8284e-a62f-11ef-84e4-8f6e2cbf0ddb", + description="This block adds a specified user to a Twitter List owned by the authenticated user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterAddListMemberBlock.Input, + output_schema=TwitterAddListMemberBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "list_id": "123456789", + "user_id": "987654321", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"add_list_member": lambda *args, **kwargs: True}, + ) + + @staticmethod + def add_list_member(credentials: TwitterCredentials, list_id: str, user_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + client.add_list_member(id=list_id, user_id=user_id, user_auth=False) + return True + except tweepy.TweepyException: + raise + except Exception: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.add_list_member( + credentials, input_data.list_id, input_data.user_id + ) + yield "success", success + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetListMembersBlock(Block): + """ + Gets the members of a specified Twitter List + """ + + class Input(UserExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["list.read", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List to get members from", + placeholder="Enter list ID", + ) + + max_results: int | None = SchemaField( + description="Maximum number of results per page (1-100)", + placeholder="Enter max results", + default=10, + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token for pagination of results", + placeholder="Enter pagination token", + default="", + advanced=True, + ) + + class Output(BlockSchema): + ids: list[str] = SchemaField(description="List of member user IDs") + usernames: list[str] = SchemaField(description="List of member usernames") + next_token: str = SchemaField(description="Next token for pagination") + + data: list[dict] = SchemaField( + description="Complete user data for list members" + ) + included: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Metadata including pagination info") + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="4dba046e-a62f-11ef-b69a-87240c84b4c7", + description="This block retrieves the members of a specified Twitter List.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetListMembersBlock.Input, + output_schema=TwitterGetListMembersBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "list_id": "123456789", + "max_results": 2, + "pagination_token": None, + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["12345", "67890"]), + ("usernames", ["testuser1", "testuser2"]), + ( + "data", + [ + {"id": "12345", "username": "testuser1"}, + {"id": "67890", "username": "testuser2"}, + ], + ), + ], + test_mock={ + "get_list_members": lambda *args, **kwargs: ( + ["12345", "67890"], + ["testuser1", "testuser2"], + [ + {"id": "12345", "username": "testuser1"}, + {"id": "67890", "username": "testuser2"}, + ], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_list_members( + credentials: TwitterCredentials, + list_id: str, + max_results: int | None, + pagination_token: str | None, + expansions: UserExpansionsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": list_id, + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + UserExpansionsBuilder(params) + .add_expansions(expansions) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_list_members(**params)) + + meta = {} + included = {} + next_token = None + user_ids = [] + usernames = [] + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + if response.includes: + included = IncludesSerializer.serialize(response.includes) + + if response.data: + data = ResponseDataSerializer.serialize_list(response.data) + user_ids = [str(user.id) for user in response.data] + usernames = [user.username for user in response.data] + return user_ids, usernames, data, included, meta, next_token + + raise Exception("List members not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, usernames, data, included, meta, next_token = self.get_list_members( + credentials, + input_data.list_id, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.tweet_fields, + input_data.user_fields, + ) + + if ids: + yield "ids", ids + if usernames: + yield "usernames", usernames + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetListMembershipsBlock(Block): + """ + Gets all Lists that a specified user is a member of + """ + + class Input(ListExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["list.read", "offline.access"] + ) + + user_id: str = SchemaField( + description="The ID of the user whose List memberships to retrieve", + placeholder="Enter user ID", + ) + + max_results: int | None = SchemaField( + description="Maximum number of results per page (1-100)", + placeholder="Enter max results", + advanced=True, + default=10, + ) + + pagination_token: str | None = SchemaField( + description="Token for pagination of results", + placeholder="Enter pagination token", + advanced=True, + default="", + ) + + class Output(BlockSchema): + list_ids: list[str] = SchemaField(description="List of list IDs") + next_token: str = SchemaField(description="Next token for pagination") + + data: list[dict] = SchemaField(description="List membership data") + included: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Metadata about pagination") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="46e6429c-a62f-11ef-81c0-2b55bc7823ba", + description="This block retrieves all Lists that a specified user is a member of.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetListMembershipsBlock.Input, + output_schema=TwitterGetListMembershipsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "user_id": "123456789", + "max_results": 1, + "pagination_token": None, + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "list_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("list_ids", ["84839422"]), + ("data", [{"id": "84839422"}]), + ], + test_mock={ + "get_list_memberships": lambda *args, **kwargs: ( + [{"id": "84839422"}], + {}, + {}, + ["84839422"], + None, + ) + }, + ) + + @staticmethod + def get_list_memberships( + credentials: TwitterCredentials, + user_id: str, + max_results: int | None, + pagination_token: str | None, + expansions: ListExpansionsFilter | None, + user_fields: TweetUserFieldsFilter | None, + list_fields: ListFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": user_id, + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + ListExpansionsBuilder(params) + .add_expansions(expansions) + .add_user_fields(user_fields) + .add_list_fields(list_fields) + .build() + ) + + response = cast(Response, client.get_list_memberships(**params)) + + meta = {} + included = {} + next_token = None + list_ids = [] + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + if response.includes: + included = IncludesSerializer.serialize(response.includes) + + if response.data: + data = ResponseDataSerializer.serialize_list(response.data) + list_ids = [str(lst.id) for lst in response.data] + return data, included, meta, list_ids, next_token + + raise Exception("List memberships not found") + + except tweepy.TweepyException: + raise + except Exception: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + data, included, meta, list_ids, next_token = self.get_list_memberships( + credentials, + input_data.user_id, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.user_fields, + input_data.list_fields, + ) + + if list_ids: + yield "list_ids", list_ids + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/lists/list_tweets_lookup.py b/autogpt_platform/backend/backend/blocks/twitter/lists/list_tweets_lookup.py new file mode 100644 index 000000000000..bda25e1d2dff --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/lists/list_tweets_lookup.py @@ -0,0 +1,218 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import TweetExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ExpansionFilter, + TweetExpansionInputs, + TweetFieldsFilter, + TweetMediaFieldsFilter, + TweetPlaceFieldsFilter, + TweetPollFieldsFilter, + TweetUserFieldsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterGetListTweetsBlock(Block): + """ + Gets tweets from a specified Twitter list + """ + + class Input(TweetExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List whose Tweets you would like to retrieve", + placeholder="Enter list ID", + ) + + max_results: int | None = SchemaField( + description="Maximum number of results per page (1-100)", + placeholder="Enter max results", + default=10, + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token for paginating through results", + placeholder="Enter pagination token", + default="", + advanced=True, + ) + + class Output(BlockSchema): + # Common outputs + tweet_ids: list[str] = SchemaField(description="List of tweet IDs") + texts: list[str] = SchemaField(description="List of tweet texts") + next_token: str = SchemaField(description="Token for next page of results") + + # Complete outputs + data: list[dict] = SchemaField(description="Complete list tweets data") + included: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField( + description="Response metadata including pagination tokens" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="6657edb0-a62f-11ef-8c10-0326d832467d", + description="This block retrieves tweets from a specified Twitter list.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetListTweetsBlock.Input, + output_schema=TwitterGetListTweetsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "list_id": "84839422", + "max_results": 1, + "pagination_token": None, + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "media_fields": None, + "place_fields": None, + "poll_fields": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("tweet_ids", ["1234567890"]), + ("texts", ["Test tweet"]), + ("data", [{"id": "1234567890", "text": "Test tweet"}]), + ], + test_mock={ + "get_list_tweets": lambda *args, **kwargs: ( + [{"id": "1234567890", "text": "Test tweet"}], + {}, + {}, + ["1234567890"], + ["Test tweet"], + None, + ) + }, + ) + + @staticmethod + def get_list_tweets( + credentials: TwitterCredentials, + list_id: str, + max_results: int | None, + pagination_token: str | None, + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": list_id, + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_list_tweets(**params)) + + meta = {} + included = {} + tweet_ids = [] + texts = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + if response.includes: + included = IncludesSerializer.serialize(response.includes) + + if response.data: + data = ResponseDataSerializer.serialize_list(response.data) + tweet_ids = [str(item.id) for item in response.data] + texts = [item.text for item in response.data] + + return data, included, meta, tweet_ids, texts, next_token + + raise Exception("No tweets found in this list") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + list_data, included, meta, tweet_ids, texts, next_token = ( + self.get_list_tweets( + credentials, + input_data.list_id, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + ) + + if tweet_ids: + yield "tweet_ids", tweet_ids + if texts: + yield "texts", texts + if next_token: + yield "next_token", next_token + if list_data: + yield "data", list_data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/lists/manage_lists.py b/autogpt_platform/backend/backend/blocks/twitter/lists/manage_lists.py new file mode 100644 index 000000000000..2ba8158f9ca6 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/lists/manage_lists.py @@ -0,0 +1,281 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterDeleteListBlock(Block): + """ + Deletes a Twitter List owned by the authenticated user + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["list.write", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List to be deleted", + placeholder="Enter list ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the deletion was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="843c6892-a62f-11ef-a5c8-b71239a78d3b", + description="This block deletes a specified Twitter List owned by the authenticated user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterDeleteListBlock.Input, + output_schema=TwitterDeleteListBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={"list_id": "1234567890", "credentials": TEST_CREDENTIALS_INPUT}, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"delete_list": lambda *args, **kwargs: True}, + ) + + @staticmethod + def delete_list(credentials: TwitterCredentials, list_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.delete_list(id=list_id, user_auth=False) + return True + + except tweepy.TweepyException: + raise + except Exception: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.delete_list(credentials, input_data.list_id) + yield "success", success + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterUpdateListBlock(Block): + """ + Updates a Twitter List owned by the authenticated user + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["list.write", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List to be updated", + placeholder="Enter list ID", + advanced=False, + ) + + name: str | None = SchemaField( + description="New name for the List", + placeholder="Enter list name", + default="", + advanced=False, + ) + + description: str | None = SchemaField( + description="New description for the List", + placeholder="Enter list description", + default="", + advanced=False, + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the update was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="7d12630a-a62f-11ef-90c9-8f5a996612c3", + description="This block updates a specified Twitter List owned by the authenticated user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterUpdateListBlock.Input, + output_schema=TwitterUpdateListBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "list_id": "1234567890", + "name": "Updated List Name", + "description": "Updated List Description", + "private": True, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"update_list": lambda *args, **kwargs: True}, + ) + + @staticmethod + def update_list( + credentials: TwitterCredentials, + list_id: str, + name: str | None, + description: str | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.update_list( + id=list_id, + name=None if name == "" else name, + description=None if description == "" else description, + user_auth=False, + ) + return True + + except tweepy.TweepyException: + raise + except Exception: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.update_list( + credentials, input_data.list_id, input_data.name, input_data.description + ) + yield "success", success + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterCreateListBlock(Block): + """ + Creates a Twitter List owned by the authenticated user + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["list.write", "offline.access"] + ) + + name: str = SchemaField( + description="The name of the List to be created", + placeholder="Enter list name", + advanced=False, + default="", + ) + + description: str | None = SchemaField( + description="Description of the List", + placeholder="Enter list description", + advanced=False, + default="", + ) + + private: bool = SchemaField( + description="Whether the List should be private", + advanced=False, + default=False, + ) + + class Output(BlockSchema): + url: str = SchemaField(description="URL of the created list") + list_id: str = SchemaField(description="ID of the created list") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="724148ba-a62f-11ef-89ba-5349b813ef5f", + description="This block creates a new Twitter List for the authenticated user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterCreateListBlock.Input, + output_schema=TwitterCreateListBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "name": "New List Name", + "description": "New List Description", + "private": True, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("list_id", "1234567890"), + ("url", "https://twitter.com/i/lists/1234567890"), + ], + test_mock={"create_list": lambda *args, **kwargs: ("1234567890")}, + ) + + @staticmethod + def create_list( + credentials: TwitterCredentials, + name: str, + description: str | None, + private: bool, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + response = cast( + Response, + client.create_list( + name=None if name == "" else name, + description=None if description == "" else description, + private=private, + user_auth=False, + ), + ) + + list_id = str(response.data["id"]) + + return list_id + + except tweepy.TweepyException: + raise + except Exception: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + list_id = self.create_list( + credentials, input_data.name, input_data.description, input_data.private + ) + yield "list_id", list_id + yield "url", f"https://twitter.com/i/lists/{list_id}" + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/lists/pinned_lists.py b/autogpt_platform/backend/backend/blocks/twitter/lists/pinned_lists.py new file mode 100644 index 000000000000..a31d1059f654 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/lists/pinned_lists.py @@ -0,0 +1,287 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import ListExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ListExpansionInputs, + ListExpansionsFilter, + ListFieldsFilter, + TweetUserFieldsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterUnpinListBlock(Block): + """ + Enables the authenticated user to unpin a List. + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["list.write", "users.read", "tweet.read", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List to unpin", + placeholder="Enter list ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the unpin was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="a099c034-a62f-11ef-9622-47d0ceb73555", + description="This block allows the authenticated user to unpin a specified List.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterUnpinListBlock.Input, + output_schema=TwitterUnpinListBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={"list_id": "123456789", "credentials": TEST_CREDENTIALS_INPUT}, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"unpin_list": lambda *args, **kwargs: True}, + ) + + @staticmethod + def unpin_list(credentials: TwitterCredentials, list_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.unpin_list(list_id=list_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + except Exception: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.unpin_list(credentials, input_data.list_id) + yield "success", success + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterPinListBlock(Block): + """ + Enables the authenticated user to pin a List. + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["list.write", "users.read", "tweet.read", "offline.access"] + ) + + list_id: str = SchemaField( + description="The ID of the List to pin", + placeholder="Enter list ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the pin was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="8ec16e48-a62f-11ef-9f35-f3d6de43a802", + description="This block allows the authenticated user to pin a specified List.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterPinListBlock.Input, + output_schema=TwitterPinListBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={"list_id": "123456789", "credentials": TEST_CREDENTIALS_INPUT}, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"pin_list": lambda *args, **kwargs: True}, + ) + + @staticmethod + def pin_list(credentials: TwitterCredentials, list_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.pin_list(list_id=list_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + except Exception: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.pin_list(credentials, input_data.list_id) + yield "success", success + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetPinnedListsBlock(Block): + """ + Returns the Lists pinned by the authenticated user. + """ + + class Input(ListExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["lists.read", "users.read", "offline.access"] + ) + + class Output(BlockSchema): + list_ids: list[str] = SchemaField(description="List IDs of the pinned lists") + list_names: list[str] = SchemaField( + description="List names of the pinned lists" + ) + + data: list[dict] = SchemaField( + description="Response data containing pinned lists" + ) + included: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Metadata about the response") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="97e03aae-a62f-11ef-bc53-5b89cb02888f", + description="This block returns the Lists pinned by the authenticated user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetPinnedListsBlock.Input, + output_schema=TwitterGetPinnedListsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "expansions": None, + "list_fields": None, + "user_fields": None, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("list_ids", ["84839422"]), + ("list_names", ["Twitter List"]), + ("data", [{"id": "84839422", "name": "Twitter List"}]), + ], + test_mock={ + "get_pinned_lists": lambda *args, **kwargs: ( + [{"id": "84839422", "name": "Twitter List"}], + {}, + {}, + ["84839422"], + ["Twitter List"], + ) + }, + ) + + @staticmethod + def get_pinned_lists( + credentials: TwitterCredentials, + expansions: ListExpansionsFilter | None, + user_fields: TweetUserFieldsFilter | None, + list_fields: ListFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = {"user_auth": False} + + params = ( + ListExpansionsBuilder(params) + .add_expansions(expansions) + .add_user_fields(user_fields) + .add_list_fields(list_fields) + .build() + ) + + response = cast(Response, client.get_pinned_lists(**params)) + + meta = {} + included = {} + list_ids = [] + list_names = [] + + if response.meta: + meta = response.meta + + if response.includes: + included = IncludesSerializer.serialize(response.includes) + + if response.data: + data = ResponseDataSerializer.serialize_list(response.data) + list_ids = [str(item.id) for item in response.data] + list_names = [item.name for item in response.data] + return data, included, meta, list_ids, list_names + + raise Exception("Lists not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + list_data, included, meta, list_ids, list_names = self.get_pinned_lists( + credentials, + input_data.expansions, + input_data.user_fields, + input_data.list_fields, + ) + + if list_ids: + yield "list_ids", list_ids + if list_names: + yield "list_names", list_names + if list_data: + yield "data", list_data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/spaces/search_spaces.py b/autogpt_platform/backend/backend/blocks/twitter/spaces/search_spaces.py new file mode 100644 index 000000000000..77b28fa65438 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/spaces/search_spaces.py @@ -0,0 +1,197 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import SpaceExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + SpaceExpansionInputs, + SpaceExpansionsFilter, + SpaceFieldsFilter, + SpaceStatesFilter, + TweetUserFieldsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterSearchSpacesBlock(Block): + """ + Returns live or scheduled Spaces matching specified search terms [for a week only] + """ + + class Input(SpaceExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["spaces.read", "users.read", "tweet.read", "offline.access"] + ) + + query: str = SchemaField( + description="Search term to find in Space titles", + placeholder="Enter search query", + ) + + max_results: int | None = SchemaField( + description="Maximum number of results to return (1-100)", + placeholder="Enter max results", + default=10, + advanced=True, + ) + + state: SpaceStatesFilter = SchemaField( + description="Type of Spaces to return (live, scheduled, or all)", + placeholder="Enter state filter", + default=SpaceStatesFilter.all, + ) + + class Output(BlockSchema): + # Common outputs that user commonly uses + ids: list[str] = SchemaField(description="List of space IDs") + titles: list[str] = SchemaField(description="List of space titles") + host_ids: list = SchemaField(description="List of host IDs") + next_token: str = SchemaField(description="Next token for pagination") + + # Complete outputs for advanced use + data: list[dict] = SchemaField(description="Complete space data") + includes: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Metadata including pagination info") + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="aaefdd48-a62f-11ef-a73c-3f44df63e276", + description="This block searches for Twitter Spaces based on specified terms.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterSearchSpacesBlock.Input, + output_schema=TwitterSearchSpacesBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "query": "tech", + "max_results": 1, + "state": "live", + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "space_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["1234"]), + ("titles", ["Tech Talk"]), + ("host_ids", ["5678"]), + ("data", [{"id": "1234", "title": "Tech Talk", "host_ids": ["5678"]}]), + ], + test_mock={ + "search_spaces": lambda *args, **kwargs: ( + [{"id": "1234", "title": "Tech Talk", "host_ids": ["5678"]}], + {}, + {}, + ["1234"], + ["Tech Talk"], + ["5678"], + None, + ) + }, + ) + + @staticmethod + def search_spaces( + credentials: TwitterCredentials, + query: str, + max_results: int | None, + state: SpaceStatesFilter, + expansions: SpaceExpansionsFilter | None, + space_fields: SpaceFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = {"query": query, "max_results": max_results, "state": state.value} + + params = ( + SpaceExpansionsBuilder(params) + .add_expansions(expansions) + .add_space_fields(space_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.search_spaces(**params)) + + meta = {} + next_token = "" + if response.meta: + meta = response.meta + if "next_token" in meta: + next_token = meta["next_token"] + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + ids = [str(space["id"]) for space in response.data if "id" in space] + titles = [space["title"] for space in data if "title" in space] + host_ids = [space["host_ids"] for space in data if "host_ids" in space] + + return data, included, meta, ids, titles, host_ids, next_token + + raise Exception("Spaces not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + data, included, meta, ids, titles, host_ids, next_token = ( + self.search_spaces( + credentials, + input_data.query, + input_data.max_results, + input_data.state, + input_data.expansions, + input_data.space_fields, + input_data.user_fields, + ) + ) + + if ids: + yield "ids", ids + if titles: + yield "titles", titles + if host_ids: + yield "host_ids", host_ids + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "includes", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/spaces/spaces_lookup.py b/autogpt_platform/backend/backend/blocks/twitter/spaces/spaces_lookup.py new file mode 100644 index 000000000000..d4ff5459e4f3 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/spaces/spaces_lookup.py @@ -0,0 +1,653 @@ +from typing import Literal, Union, cast + +import tweepy +from pydantic import BaseModel +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import ( + SpaceExpansionsBuilder, + TweetExpansionsBuilder, + UserExpansionsBuilder, +) +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ExpansionFilter, + SpaceExpansionInputs, + SpaceExpansionsFilter, + SpaceFieldsFilter, + TweetExpansionInputs, + TweetFieldsFilter, + TweetMediaFieldsFilter, + TweetPlaceFieldsFilter, + TweetPollFieldsFilter, + TweetUserFieldsFilter, + UserExpansionInputs, + UserExpansionsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class SpaceList(BaseModel): + discriminator: Literal["space_list"] + space_ids: list[str] = SchemaField( + description="List of Space IDs to lookup (up to 100)", + placeholder="Enter Space IDs", + default_factory=list, + advanced=False, + ) + + +class UserList(BaseModel): + discriminator: Literal["user_list"] + user_ids: list[str] = SchemaField( + description="List of user IDs to lookup their Spaces (up to 100)", + placeholder="Enter user IDs", + default_factory=list, + advanced=False, + ) + + +class TwitterGetSpacesBlock(Block): + """ + Gets information about multiple Twitter Spaces specified by Space IDs or creator user IDs + """ + + class Input(SpaceExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["spaces.read", "users.read", "offline.access"] + ) + + identifier: Union[SpaceList, UserList] = SchemaField( + discriminator="discriminator", + description="Choose whether to lookup spaces by their IDs or by creator user IDs", + advanced=False, + ) + + class Output(BlockSchema): + # Common outputs + ids: list[str] = SchemaField(description="List of space IDs") + titles: list[str] = SchemaField(description="List of space titles") + + # Complete outputs for advanced use + data: list[dict] = SchemaField(description="Complete space data") + includes: dict = SchemaField( + description="Additional data requested via expansions" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="d75bd7d8-a62f-11ef-b0d8-c7a9496f617f", + description="This block retrieves information about multiple Twitter Spaces.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetSpacesBlock.Input, + output_schema=TwitterGetSpacesBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "identifier": { + "discriminator": "space_list", + "space_ids": ["1DXxyRYNejbKM"], + }, + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "space_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["1DXxyRYNejbKM"]), + ("titles", ["Test Space"]), + ( + "data", + [ + { + "id": "1DXxyRYNejbKM", + "title": "Test Space", + "host_id": "1234567", + } + ], + ), + ], + test_mock={ + "get_spaces": lambda *args, **kwargs: ( + [ + { + "id": "1DXxyRYNejbKM", + "title": "Test Space", + "host_id": "1234567", + } + ], + {}, + ["1DXxyRYNejbKM"], + ["Test Space"], + ) + }, + ) + + @staticmethod + def get_spaces( + credentials: TwitterCredentials, + identifier: Union[SpaceList, UserList], + expansions: SpaceExpansionsFilter | None, + space_fields: SpaceFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "ids": ( + identifier.space_ids if isinstance(identifier, SpaceList) else None + ), + "user_ids": ( + identifier.user_ids if isinstance(identifier, UserList) else None + ), + } + + params = ( + SpaceExpansionsBuilder(params) + .add_expansions(expansions) + .add_space_fields(space_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_spaces(**params)) + + ids = [] + titles = [] + + included = IncludesSerializer.serialize(response.includes) + + if response.data: + data = ResponseDataSerializer.serialize_list(response.data) + ids = [space["id"] for space in data if "id" in space] + titles = [space["title"] for space in data if "title" in space] + + return data, included, ids, titles + + raise Exception("No spaces found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + data, included, ids, titles = self.get_spaces( + credentials, + input_data.identifier, + input_data.expansions, + input_data.space_fields, + input_data.user_fields, + ) + + if ids: + yield "ids", ids + if titles: + yield "titles", titles + + if data: + yield "data", data + if included: + yield "includes", included + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetSpaceByIdBlock(Block): + """ + Gets information about a single Twitter Space specified by Space ID + """ + + class Input(SpaceExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["spaces.read", "users.read", "offline.access"] + ) + + space_id: str = SchemaField( + description="Space ID to lookup", + placeholder="Enter Space ID", + ) + + class Output(BlockSchema): + # Common outputs + id: str = SchemaField(description="Space ID") + title: str = SchemaField(description="Space title") + host_ids: list[str] = SchemaField(description="Host ID") + + # Complete outputs for advanced use + data: dict = SchemaField(description="Complete space data") + includes: dict = SchemaField( + description="Additional data requested via expansions" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="c79700de-a62f-11ef-ab20-fb32bf9d5a9d", + description="This block retrieves information about a single Twitter Space.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetSpaceByIdBlock.Input, + output_schema=TwitterGetSpaceByIdBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "space_id": "1DXxyRYNejbKM", + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "space_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", "1DXxyRYNejbKM"), + ("title", "Test Space"), + ("host_ids", ["1234567"]), + ( + "data", + { + "id": "1DXxyRYNejbKM", + "title": "Test Space", + "host_ids": ["1234567"], + }, + ), + ], + test_mock={ + "get_space": lambda *args, **kwargs: ( + { + "id": "1DXxyRYNejbKM", + "title": "Test Space", + "host_ids": ["1234567"], + }, + {}, + ) + }, + ) + + @staticmethod + def get_space( + credentials: TwitterCredentials, + space_id: str, + expansions: SpaceExpansionsFilter | None, + space_fields: SpaceFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": space_id, + } + + params = ( + SpaceExpansionsBuilder(params) + .add_expansions(expansions) + .add_space_fields(space_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_space(**params)) + + includes = {} + if response.includes: + for key, value in response.includes.items(): + if isinstance(value, list): + includes[key] = [ + item.data if hasattr(item, "data") else item + for item in value + ] + else: + includes[key] = value.data if hasattr(value, "data") else value + + data = {} + if response.data: + for key, value in response.data.items(): + if isinstance(value, list): + data[key] = [ + item.data if hasattr(item, "data") else item + for item in value + ] + else: + data[key] = value.data if hasattr(value, "data") else value + + return data, includes + + raise Exception("Space not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + space_data, includes = self.get_space( + credentials, + input_data.space_id, + input_data.expansions, + input_data.space_fields, + input_data.user_fields, + ) + + # Common outputs + if space_data: + if "id" in space_data: + yield "id", space_data.get("id") + + if "title" in space_data: + yield "title", space_data.get("title") + + if "host_ids" in space_data: + yield "host_ids", space_data.get("host_ids") + + if space_data: + yield "data", space_data + if includes: + yield "includes", includes + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +# Not tested yet, might have some problem +class TwitterGetSpaceBuyersBlock(Block): + """ + Gets list of users who purchased a ticket to the requested Space + """ + + class Input(UserExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["spaces.read", "users.read", "offline.access"] + ) + + space_id: str = SchemaField( + description="Space ID to lookup buyers for", + placeholder="Enter Space ID", + ) + + class Output(BlockSchema): + # Common outputs + buyer_ids: list[str] = SchemaField(description="List of buyer IDs") + usernames: list[str] = SchemaField(description="List of buyer usernames") + + # Complete outputs for advanced use + data: list[dict] = SchemaField(description="Complete space buyers data") + includes: dict = SchemaField( + description="Additional data requested via expansions" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="c1c121a8-a62f-11ef-8b0e-d7b85f96a46f", + description="This block retrieves a list of users who purchased tickets to a Twitter Space.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetSpaceBuyersBlock.Input, + output_schema=TwitterGetSpaceBuyersBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "space_id": "1DXxyRYNejbKM", + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("buyer_ids", ["2244994945"]), + ("usernames", ["testuser"]), + ( + "data", + [{"id": "2244994945", "username": "testuser", "name": "Test User"}], + ), + ], + test_mock={ + "get_space_buyers": lambda *args, **kwargs: ( + [{"id": "2244994945", "username": "testuser", "name": "Test User"}], + {}, + ["2244994945"], + ["testuser"], + ) + }, + ) + + @staticmethod + def get_space_buyers( + credentials: TwitterCredentials, + space_id: str, + expansions: UserExpansionsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": space_id, + } + + params = ( + UserExpansionsBuilder(params) + .add_expansions(expansions) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_space_buyers(**params)) + + included = IncludesSerializer.serialize(response.includes) + + if response.data: + data = ResponseDataSerializer.serialize_list(response.data) + buyer_ids = [buyer["id"] for buyer in data] + usernames = [buyer["username"] for buyer in data] + + return data, included, buyer_ids, usernames + + raise Exception("No buyers found for this Space") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + buyers_data, included, buyer_ids, usernames = self.get_space_buyers( + credentials, + input_data.space_id, + input_data.expansions, + input_data.user_fields, + ) + + if buyer_ids: + yield "buyer_ids", buyer_ids + if usernames: + yield "usernames", usernames + + if buyers_data: + yield "data", buyers_data + if included: + yield "includes", included + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetSpaceTweetsBlock(Block): + """ + Gets list of Tweets shared in the requested Space + """ + + class Input(TweetExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["spaces.read", "users.read", "offline.access"] + ) + + space_id: str = SchemaField( + description="Space ID to lookup tweets for", + placeholder="Enter Space ID", + ) + + class Output(BlockSchema): + # Common outputs + tweet_ids: list[str] = SchemaField(description="List of tweet IDs") + texts: list[str] = SchemaField(description="List of tweet texts") + + # Complete outputs for advanced use + data: list[dict] = SchemaField(description="Complete space tweets data") + includes: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Response metadata") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="b69731e6-a62f-11ef-b2d4-1bf14dd6aee4", + description="This block retrieves tweets shared in a Twitter Space.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetSpaceTweetsBlock.Input, + output_schema=TwitterGetSpaceTweetsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "space_id": "1DXxyRYNejbKM", + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "media_fields": None, + "place_fields": None, + "poll_fields": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("tweet_ids", ["1234567890"]), + ("texts", ["Test tweet"]), + ("data", [{"id": "1234567890", "text": "Test tweet"}]), + ], + test_mock={ + "get_space_tweets": lambda *args, **kwargs: ( + [{"id": "1234567890", "text": "Test tweet"}], # data + {}, + ["1234567890"], + ["Test tweet"], + {}, + ) + }, + ) + + @staticmethod + def get_space_tweets( + credentials: TwitterCredentials, + space_id: str, + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": space_id, + } + + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_space_tweets(**params)) + + included = IncludesSerializer.serialize(response.includes) + + if response.data: + data = ResponseDataSerializer.serialize_list(response.data) + tweet_ids = [str(tweet["id"]) for tweet in data] + texts = [tweet["text"] for tweet in data] + + meta = response.meta or {} + + return data, included, tweet_ids, texts, meta + + raise Exception("No tweets found for this Space") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + tweets_data, included, tweet_ids, texts, meta = self.get_space_tweets( + credentials, + input_data.space_id, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + + if tweet_ids: + yield "tweet_ids", tweet_ids + if texts: + yield "texts", texts + + if tweets_data: + yield "data", tweets_data + if included: + yield "includes", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/tweepy_exceptions.py b/autogpt_platform/backend/backend/blocks/twitter/tweepy_exceptions.py new file mode 100644 index 000000000000..c1900269982f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/tweepy_exceptions.py @@ -0,0 +1,20 @@ +import tweepy + + +def handle_tweepy_exception(e: Exception) -> str: + if isinstance(e, tweepy.BadRequest): + return f"Bad Request (400): {str(e)}" + elif isinstance(e, tweepy.Unauthorized): + return f"Unauthorized (401): {str(e)}" + elif isinstance(e, tweepy.Forbidden): + return f"Forbidden (403): {str(e)}" + elif isinstance(e, tweepy.NotFound): + return f"Not Found (404): {str(e)}" + elif isinstance(e, tweepy.TooManyRequests): + return f"Too Many Requests (429): {str(e)}" + elif isinstance(e, tweepy.TwitterServerError): + return f"Twitter Server Error (5xx): {str(e)}" + elif isinstance(e, tweepy.TweepyException): + return f"Tweepy Error: {str(e)}" + else: + return f"Unexpected error: {str(e)}" diff --git a/autogpt_platform/backend/backend/blocks/twitter/tweets/bookmark.py b/autogpt_platform/backend/backend/blocks/twitter/tweets/bookmark.py new file mode 100644 index 000000000000..ec8976fc2f61 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/tweets/bookmark.py @@ -0,0 +1,376 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import TweetExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ExpansionFilter, + TweetExpansionInputs, + TweetFieldsFilter, + TweetMediaFieldsFilter, + TweetPlaceFieldsFilter, + TweetPollFieldsFilter, + TweetUserFieldsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterBookmarkTweetBlock(Block): + """ + Bookmark a tweet on Twitter + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "bookmark.write", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet to bookmark", + placeholder="Enter tweet ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the bookmark was successful") + error: str = SchemaField(description="Error message if the bookmark failed") + + def __init__(self): + super().__init__( + id="f33d67be-a62f-11ef-a797-ff83ec29ee8e", + description="This block bookmarks a tweet on Twitter.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterBookmarkTweetBlock.Input, + output_schema=TwitterBookmarkTweetBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"bookmark_tweet": lambda *args, **kwargs: True}, + ) + + @staticmethod + def bookmark_tweet( + credentials: TwitterCredentials, + tweet_id: str, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.bookmark(tweet_id) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.bookmark_tweet(credentials, input_data.tweet_id) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetBookmarkedTweetsBlock(Block): + """ + Get All your bookmarked tweets from Twitter + """ + + class Input(TweetExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "bookmark.read", "users.read", "offline.access"] + ) + + max_results: int | None = SchemaField( + description="Maximum number of results to return (1-100)", + placeholder="Enter max results", + default=10, + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token for pagination", + placeholder="Enter pagination token", + default="", + advanced=True, + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + id: list[str] = SchemaField(description="All Tweet IDs") + text: list[str] = SchemaField(description="All Tweet texts") + userId: list[str] = SchemaField(description="IDs of the tweet authors") + userName: list[str] = SchemaField(description="Usernames of the tweet authors") + + # Complete Outputs for advanced use + data: list[dict] = SchemaField(description="Complete Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField( + description="Provides metadata such as pagination info (next_token) or result counts" + ) + next_token: str = SchemaField(description="Next token for pagination") + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="ed26783e-a62f-11ef-9a21-c77c57dd8a1f", + description="This block retrieves bookmarked tweets from Twitter.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetBookmarkedTweetsBlock.Input, + output_schema=TwitterGetBookmarkedTweetsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "max_results": 2, + "pagination_token": None, + "expansions": None, + "media_fields": None, + "place_fields": None, + "poll_fields": None, + "tweet_fields": None, + "user_fields": None, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", ["1234567890"]), + ("text", ["Test tweet"]), + ("userId", ["12345"]), + ("userName", ["testuser"]), + ("data", [{"id": "1234567890", "text": "Test tweet"}]), + ], + test_mock={ + "get_bookmarked_tweets": lambda *args, **kwargs: ( + ["1234567890"], + ["Test tweet"], + ["12345"], + ["testuser"], + [{"id": "1234567890", "text": "Test tweet"}], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_bookmarked_tweets( + credentials: TwitterCredentials, + max_results: int | None, + pagination_token: str | None, + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + } + + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast( + Response, + client.get_bookmarks(**params), + ) + + meta = {} + tweet_ids = [] + tweet_texts = [] + user_ids = [] + user_names = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + tweet_ids = [str(tweet.id) for tweet in response.data] + tweet_texts = [tweet.text for tweet in response.data] + + if "users" in included: + for user in included["users"]: + user_ids.append(str(user["id"])) + user_names.append(user["username"]) + + return ( + tweet_ids, + tweet_texts, + user_ids, + user_names, + data, + included, + meta, + next_token, + ) + + raise Exception("No bookmarked tweets found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, texts, user_ids, user_names, data, included, meta, next_token = ( + self.get_bookmarked_tweets( + credentials, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + ) + if ids: + yield "id", ids + if texts: + yield "text", texts + if user_ids: + yield "userId", user_ids + if user_names: + yield "userName", user_names + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + if next_token: + yield "next_token", next_token + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterRemoveBookmarkTweetBlock(Block): + """ + Remove a bookmark for a tweet on Twitter + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "bookmark.write", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet to remove bookmark from", + placeholder="Enter tweet ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the bookmark was successfully removed" + ) + error: str = SchemaField( + description="Error message if the bookmark removal failed" + ) + + def __init__(self): + super().__init__( + id="e4100684-a62f-11ef-9be9-770cb41a2616", + description="This block removes a bookmark from a tweet on Twitter.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterRemoveBookmarkTweetBlock.Input, + output_schema=TwitterRemoveBookmarkTweetBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"remove_bookmark_tweet": lambda *args, **kwargs: True}, + ) + + @staticmethod + def remove_bookmark_tweet( + credentials: TwitterCredentials, + tweet_id: str, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.remove_bookmark(tweet_id) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.remove_bookmark_tweet(credentials, input_data.tweet_id) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/tweets/hide.py b/autogpt_platform/backend/backend/blocks/twitter/tweets/hide.py new file mode 100644 index 000000000000..65faa315aeae --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/tweets/hide.py @@ -0,0 +1,157 @@ +import tweepy + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterHideReplyBlock(Block): + """ + Hides a reply of one of your tweets + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "tweet.moderate.write", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet reply to hide", + placeholder="Enter tweet ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the operation was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="07d58b3e-a630-11ef-a030-93701d1a465e", + description="This block hides a reply to a tweet.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterHideReplyBlock.Input, + output_schema=TwitterHideReplyBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"hide_reply": lambda *args, **kwargs: True}, + ) + + @staticmethod + def hide_reply( + credentials: TwitterCredentials, + tweet_id: str, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.hide_reply(id=tweet_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.hide_reply( + credentials, + input_data.tweet_id, + ) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterUnhideReplyBlock(Block): + """ + Unhides a reply to a tweet + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "tweet.moderate.write", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet reply to unhide", + placeholder="Enter tweet ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the operation was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="fcf9e4e4-a62f-11ef-9d85-57d3d06b616a", + description="This block unhides a reply to a tweet.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterUnhideReplyBlock.Input, + output_schema=TwitterUnhideReplyBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"unhide_reply": lambda *args, **kwargs: True}, + ) + + @staticmethod + def unhide_reply( + credentials: TwitterCredentials, + tweet_id: str, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.unhide_reply(id=tweet_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.unhide_reply( + credentials, + input_data.tweet_id, + ) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/tweets/like.py b/autogpt_platform/backend/backend/blocks/twitter/tweets/like.py new file mode 100644 index 000000000000..8bbc30e8e9bb --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/tweets/like.py @@ -0,0 +1,581 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import ( + TweetExpansionsBuilder, + UserExpansionsBuilder, +) +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ExpansionFilter, + TweetExpansionInputs, + TweetFieldsFilter, + TweetMediaFieldsFilter, + TweetPlaceFieldsFilter, + TweetPollFieldsFilter, + TweetUserFieldsFilter, + UserExpansionInputs, + UserExpansionsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterLikeTweetBlock(Block): + """ + Likes a tweet + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "like.write", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet to like", + placeholder="Enter tweet ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the operation was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="4d0b4c5c-a630-11ef-8e08-1b14c507b347", + description="This block likes a tweet.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterLikeTweetBlock.Input, + output_schema=TwitterLikeTweetBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"like_tweet": lambda *args, **kwargs: True}, + ) + + @staticmethod + def like_tweet( + credentials: TwitterCredentials, + tweet_id: str, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.like(tweet_id=tweet_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.like_tweet( + credentials, + input_data.tweet_id, + ) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetLikingUsersBlock(Block): + """ + Gets information about users who liked a one of your tweet + """ + + class Input(UserExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "like.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet to get liking users for", + placeholder="Enter tweet ID", + ) + max_results: int | None = SchemaField( + description="Maximum number of results to return (1-100)", + placeholder="Enter max results", + default=10, + advanced=True, + ) + pagination_token: str | None = SchemaField( + description="Token for getting next/previous page of results", + placeholder="Enter pagination token", + default="", + advanced=True, + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + id: list[str] = SchemaField(description="All User IDs who liked the tweet") + username: list[str] = SchemaField( + description="All User usernames who liked the tweet" + ) + next_token: str = SchemaField(description="Next token for pagination") + + # Complete Outputs for advanced use + data: list[dict] = SchemaField(description="Complete Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField( + description="Provides metadata such as pagination info (next_token) or result counts" + ) + + # error + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="34275000-a630-11ef-b01e-5f00d9077c08", + description="This block gets information about users who liked a tweet.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetLikingUsersBlock.Input, + output_schema=TwitterGetLikingUsersBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "max_results": 1, + "pagination_token": None, + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", ["1234567890"]), + ("username", ["testuser"]), + ("data", [{"id": "1234567890", "username": "testuser"}]), + ], + test_mock={ + "get_liking_users": lambda *args, **kwargs: ( + ["1234567890"], + ["testuser"], + [{"id": "1234567890", "username": "testuser"}], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_liking_users( + credentials: TwitterCredentials, + tweet_id: str, + max_results: int | None, + pagination_token: str | None, + expansions: UserExpansionsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": tweet_id, + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + UserExpansionsBuilder(params) + .add_expansions(expansions) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_liking_users(**params)) + + if not response.data and not response.meta: + raise Exception("No liking users found") + + meta = {} + user_ids = [] + usernames = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + user_ids = [str(user.id) for user in response.data] + usernames = [user.username for user in response.data] + + return user_ids, usernames, data, included, meta, next_token + + raise Exception("No liking users found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, usernames, data, included, meta, next_token = self.get_liking_users( + credentials, + input_data.tweet_id, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.tweet_fields, + input_data.user_fields, + ) + if ids: + yield "id", ids + if usernames: + yield "username", usernames + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetLikedTweetsBlock(Block): + """ + Gets information about tweets liked by you + """ + + class Input(TweetExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "like.read", "offline.access"] + ) + + user_id: str = SchemaField( + description="ID of the user to get liked tweets for", + placeholder="Enter user ID", + ) + max_results: int | None = SchemaField( + description="Maximum number of results to return (5-100)", + placeholder="100", + default=10, + advanced=True, + ) + pagination_token: str | None = SchemaField( + description="Token for getting next/previous page of results", + placeholder="Enter pagination token", + default="", + advanced=True, + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + ids: list[str] = SchemaField(description="All Tweet IDs") + texts: list[str] = SchemaField(description="All Tweet texts") + userIds: list[str] = SchemaField( + description="List of user ids that authored the tweets" + ) + userNames: list[str] = SchemaField( + description="List of user names that authored the tweets" + ) + next_token: str = SchemaField(description="Next token for pagination") + + # Complete Outputs for advanced use + data: list[dict] = SchemaField(description="Complete Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField( + description="Provides metadata such as pagination info (next_token) or result counts" + ) + + # error + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="292e7c78-a630-11ef-9f40-df5dffaca106", + description="This block gets information about tweets liked by a user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetLikedTweetsBlock.Input, + output_schema=TwitterGetLikedTweetsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "user_id": "1234567890", + "max_results": 2, + "pagination_token": None, + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "media_fields": None, + "place_fields": None, + "poll_fields": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["12345", "67890"]), + ("texts", ["Tweet 1", "Tweet 2"]), + ("userIds", ["67890", "67891"]), + ("userNames", ["testuser1", "testuser2"]), + ( + "data", + [ + {"id": "12345", "text": "Tweet 1"}, + {"id": "67890", "text": "Tweet 2"}, + ], + ), + ], + test_mock={ + "get_liked_tweets": lambda *args, **kwargs: ( + ["12345", "67890"], + ["Tweet 1", "Tweet 2"], + ["67890", "67891"], + ["testuser1", "testuser2"], + [ + {"id": "12345", "text": "Tweet 1"}, + {"id": "67890", "text": "Tweet 2"}, + ], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_liked_tweets( + credentials: TwitterCredentials, + user_id: str, + max_results: int | None, + pagination_token: str | None, + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": user_id, + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_liked_tweets(**params)) + + if not response.data and not response.meta: + raise Exception("No liked tweets found") + + meta = {} + tweet_ids = [] + tweet_texts = [] + user_ids = [] + user_names = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + tweet_ids = [str(tweet.id) for tweet in response.data] + tweet_texts = [tweet.text for tweet in response.data] + + if "users" in response.includes: + user_ids = [str(user["id"]) for user in response.includes["users"]] + user_names = [ + user["username"] for user in response.includes["users"] + ] + + return ( + tweet_ids, + tweet_texts, + user_ids, + user_names, + data, + included, + meta, + next_token, + ) + + raise Exception("No liked tweets found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, texts, user_ids, user_names, data, included, meta, next_token = ( + self.get_liked_tweets( + credentials, + input_data.user_id, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + ) + if ids: + yield "ids", ids + if texts: + yield "texts", texts + if user_ids: + yield "userIds", user_ids + if user_names: + yield "userNames", user_names + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterUnlikeTweetBlock(Block): + """ + Unlikes a tweet that was previously liked + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "like.write", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet to unlike", + placeholder="Enter tweet ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the operation was successful") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="1ed5eab8-a630-11ef-8e21-cbbbc80cbb85", + description="This block unlikes a tweet.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterUnlikeTweetBlock.Input, + output_schema=TwitterUnlikeTweetBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"unlike_tweet": lambda *args, **kwargs: True}, + ) + + @staticmethod + def unlike_tweet( + credentials: TwitterCredentials, + tweet_id: str, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.unlike(tweet_id=tweet_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.unlike_tweet( + credentials, + input_data.tweet_id, + ) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/tweets/manage.py b/autogpt_platform/backend/backend/blocks/twitter/tweets/manage.py new file mode 100644 index 000000000000..6dca0d74c833 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/tweets/manage.py @@ -0,0 +1,550 @@ +from datetime import datetime +from typing import List, Literal, Optional, Union, cast + +import tweepy +from pydantic import BaseModel +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import ( + TweetDurationBuilder, + TweetExpansionsBuilder, + TweetPostBuilder, + TweetSearchBuilder, +) +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ExpansionFilter, + TweetExpansionInputs, + TweetFieldsFilter, + TweetMediaFieldsFilter, + TweetPlaceFieldsFilter, + TweetPollFieldsFilter, + TweetReplySettingsFilter, + TweetTimeWindowInputs, + TweetUserFieldsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class Media(BaseModel): + discriminator: Literal["media"] + media_ids: Optional[List[str]] = None + media_tagged_user_ids: Optional[List[str]] = None + + +class DeepLink(BaseModel): + discriminator: Literal["deep_link"] + direct_message_deep_link: Optional[str] = None + + +class Poll(BaseModel): + discriminator: Literal["poll"] + poll_options: Optional[List[str]] = None + poll_duration_minutes: Optional[int] = None + + +class Place(BaseModel): + discriminator: Literal["place"] + place_id: Optional[str] = None + + +class Quote(BaseModel): + discriminator: Literal["quote"] + quote_tweet_id: Optional[str] = None + + +class TwitterPostTweetBlock(Block): + """ + Create a tweet on Twitter with the option to include one additional element such as a media, quote, or deep link. + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "tweet.write", "users.read", "offline.access"] + ) + + tweet_text: str | None = SchemaField( + description="Text of the tweet to post", + placeholder="Enter your tweet", + default=None, + advanced=False, + ) + + for_super_followers_only: bool = SchemaField( + description="Tweet exclusively for Super Followers", + placeholder="Enter for super followers only", + advanced=True, + default=False, + ) + + attachment: Union[Media, DeepLink, Poll, Place, Quote] | None = SchemaField( + discriminator="discriminator", + description="Additional tweet data (media, deep link, poll, place or quote)", + advanced=False, + default=Media(discriminator="media"), + ) + + exclude_reply_user_ids: Optional[List[str]] = SchemaField( + description="User IDs to exclude from reply Tweet thread. [ex - 6253282]", + placeholder="Enter user IDs to exclude", + advanced=True, + default=None, + ) + + in_reply_to_tweet_id: Optional[str] = SchemaField( + description="Tweet ID being replied to. Please note that in_reply_to_tweet_id needs to be in the request if exclude_reply_user_ids is present", + default=None, + placeholder="Enter in reply to tweet ID", + advanced=True, + ) + + reply_settings: TweetReplySettingsFilter = SchemaField( + description="Who can reply to the Tweet (mentionedUsers or following)", + placeholder="Enter reply settings", + advanced=True, + default=TweetReplySettingsFilter(All_Users=True), + ) + + class Output(BlockSchema): + tweet_id: str = SchemaField(description="ID of the created tweet") + tweet_url: str = SchemaField(description="URL to the tweet") + error: str = SchemaField( + description="Error message if the tweet posting failed" + ) + + def __init__(self): + super().__init__( + id="7bb0048a-a630-11ef-aeb8-abc0dadb9b12", + description="This block posts a tweet on Twitter.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterPostTweetBlock.Input, + output_schema=TwitterPostTweetBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_text": "This is a test tweet.", + "credentials": TEST_CREDENTIALS_INPUT, + "attachment": { + "discriminator": "deep_link", + "direct_message_deep_link": "https://twitter.com/messages/compose", + }, + "for_super_followers_only": False, + "exclude_reply_user_ids": [], + "in_reply_to_tweet_id": "", + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("tweet_id", "1234567890"), + ("tweet_url", "https://twitter.com/user/status/1234567890"), + ], + test_mock={ + "post_tweet": lambda *args, **kwargs: ( + "1234567890", + "https://twitter.com/user/status/1234567890", + ) + }, + ) + + def post_tweet( + self, + credentials: TwitterCredentials, + input_txt: str | None, + attachment: Union[Media, DeepLink, Poll, Place, Quote] | None, + for_super_followers_only: bool, + exclude_reply_user_ids: Optional[List[str]], + in_reply_to_tweet_id: Optional[str], + reply_settings: TweetReplySettingsFilter, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = ( + TweetPostBuilder() + .add_text(input_txt) + .add_super_followers(for_super_followers_only) + .add_reply_settings( + exclude_reply_user_ids or [], + in_reply_to_tweet_id or "", + reply_settings, + ) + ) + + if isinstance(attachment, Media): + params.add_media( + attachment.media_ids or [], attachment.media_tagged_user_ids or [] + ) + elif isinstance(attachment, DeepLink): + params.add_deep_link(attachment.direct_message_deep_link or "") + elif isinstance(attachment, Poll): + params.add_poll_options(attachment.poll_options or []) + params.add_poll_duration(attachment.poll_duration_minutes or 0) + elif isinstance(attachment, Place): + params.add_place(attachment.place_id or "") + elif isinstance(attachment, Quote): + params.add_quote(attachment.quote_tweet_id or "") + + tweet = cast(Response, client.create_tweet(**params.build())) + + if not tweet.data: + raise Exception("Failed to create tweet") + + tweet_id = tweet.data["id"] + tweet_url = f"https://twitter.com/user/status/{tweet_id}" + return str(tweet_id), tweet_url + + except tweepy.TweepyException: + raise + except Exception: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + tweet_id, tweet_url = self.post_tweet( + credentials, + input_data.tweet_text, + input_data.attachment, + input_data.for_super_followers_only, + input_data.exclude_reply_user_ids, + input_data.in_reply_to_tweet_id, + input_data.reply_settings, + ) + yield "tweet_id", tweet_id + yield "tweet_url", tweet_url + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterDeleteTweetBlock(Block): + """ + Deletes a tweet on Twitter using twitter Id + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "tweet.write", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet to delete", + placeholder="Enter tweet ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the tweet was successfully deleted" + ) + error: str = SchemaField( + description="Error message if the tweet deletion failed" + ) + + def __init__(self): + super().__init__( + id="761babf0-a630-11ef-a03d-abceb082f58f", + description="This block deletes a tweet on Twitter.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterDeleteTweetBlock.Input, + output_schema=TwitterDeleteTweetBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"delete_tweet": lambda *args, **kwargs: True}, + ) + + @staticmethod + def delete_tweet(credentials: TwitterCredentials, tweet_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + client.delete_tweet(id=tweet_id, user_auth=False) + return True + except tweepy.TweepyException: + raise + except Exception: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.delete_tweet( + credentials, + input_data.tweet_id, + ) + yield "success", success + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterSearchRecentTweetsBlock(Block): + """ + Searches all public Tweets in Twitter history + """ + + class Input(TweetExpansionInputs, TweetTimeWindowInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "offline.access"] + ) + + query: str = SchemaField( + description="Search query (up to 1024 characters)", + placeholder="Enter search query", + ) + + max_results: int = SchemaField( + description="Maximum number of results per page (10-500)", + placeholder="Enter max results", + default=10, + advanced=True, + ) + + pagination: str | None = SchemaField( + description="Token for pagination", + default="", + placeholder="Enter pagination token", + advanced=True, + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + tweet_ids: list[str] = SchemaField(description="All Tweet IDs") + tweet_texts: list[str] = SchemaField(description="All Tweet texts") + next_token: str = SchemaField(description="Next token for pagination") + + # Complete Outputs for advanced use + data: list[dict] = SchemaField(description="Complete Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField( + description="Provides metadata such as pagination info (next_token) or result counts" + ) + + # error + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="53e5cf8e-a630-11ef-ba85-df6d666fa5d5", + description="This block searches all public Tweets in Twitter history.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterSearchRecentTweetsBlock.Input, + output_schema=TwitterSearchRecentTweetsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "query": "from:twitterapi #twitterapi", + "credentials": TEST_CREDENTIALS_INPUT, + "max_results": 2, + "start_time": "2024-12-14T18:30:00.000Z", + "end_time": "2024-12-17T18:30:00.000Z", + "since_id": None, + "until_id": None, + "sort_order": None, + "pagination": None, + "expansions": None, + "media_fields": None, + "place_fields": None, + "poll_fields": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("tweet_ids", ["1373001119480344583", "1372627771717869568"]), + ( + "tweet_texts", + [ + "Looking to get started with the Twitter API but new to APIs in general?", + "Thanks to everyone who joined and made today a great session!", + ], + ), + ( + "data", + [ + { + "id": "1373001119480344583", + "text": "Looking to get started with the Twitter API but new to APIs in general?", + }, + { + "id": "1372627771717869568", + "text": "Thanks to everyone who joined and made today a great session!", + }, + ], + ), + ], + test_mock={ + "search_tweets": lambda *args, **kwargs: ( + ["1373001119480344583", "1372627771717869568"], + [ + "Looking to get started with the Twitter API but new to APIs in general?", + "Thanks to everyone who joined and made today a great session!", + ], + [ + { + "id": "1373001119480344583", + "text": "Looking to get started with the Twitter API but new to APIs in general?", + }, + { + "id": "1372627771717869568", + "text": "Thanks to everyone who joined and made today a great session!", + }, + ], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def search_tweets( + credentials: TwitterCredentials, + query: str, + max_results: int, + start_time: datetime | None, + end_time: datetime | None, + since_id: str | None, + until_id: str | None, + sort_order: str | None, + pagination: str | None, + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + # Building common params + params = ( + TweetSearchBuilder() + .add_query(query) + .add_pagination(max_results, pagination) + .build() + ) + + # Adding expansions to params If required by the user + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + # Adding time window to params If required by the user + params = ( + TweetDurationBuilder(params) + .add_start_time(start_time) + .add_end_time(end_time) + .add_since_id(since_id) + .add_until_id(until_id) + .add_sort_order(sort_order) + .build() + ) + + response = cast(Response, client.search_recent_tweets(**params)) + + if not response.data and not response.meta: + raise Exception("No tweets found") + + meta = {} + tweet_ids = [] + tweet_texts = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + tweet_ids = [str(tweet.id) for tweet in response.data] + tweet_texts = [tweet.text for tweet in response.data] + + return tweet_ids, tweet_texts, data, included, meta, next_token + + raise Exception("No tweets found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, texts, data, included, meta, next_token = self.search_tweets( + credentials, + input_data.query, + input_data.max_results, + input_data.start_time, + input_data.end_time, + input_data.since_id, + input_data.until_id, + input_data.sort_order, + input_data.pagination, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + if ids: + yield "tweet_ids", ids + if texts: + yield "tweet_texts", texts + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/tweets/quote.py b/autogpt_platform/backend/backend/blocks/twitter/tweets/quote.py new file mode 100644 index 000000000000..b15271b07282 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/tweets/quote.py @@ -0,0 +1,224 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import TweetExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ExpansionFilter, + TweetExcludesFilter, + TweetExpansionInputs, + TweetFieldsFilter, + TweetMediaFieldsFilter, + TweetPlaceFieldsFilter, + TweetPollFieldsFilter, + TweetUserFieldsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterGetQuoteTweetsBlock(Block): + """ + Gets quote tweets for a specified tweet ID + """ + + class Input(TweetExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet to get quotes for", + placeholder="Enter tweet ID", + ) + + max_results: int | None = SchemaField( + description="Number of results to return (max 100)", + default=10, + advanced=True, + ) + + exclude: TweetExcludesFilter | None = SchemaField( + description="Types of tweets to exclude", advanced=True, default=None + ) + + pagination_token: str | None = SchemaField( + description="Token for pagination", + advanced=True, + default="", + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + ids: list = SchemaField(description="All Tweet IDs ") + texts: list = SchemaField(description="All Tweet texts") + next_token: str = SchemaField(description="Next token for pagination") + + # Complete Outputs for advanced use + data: list[dict] = SchemaField(description="Complete Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField( + description="Provides metadata such as pagination info (next_token) or result counts" + ) + + # error + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="9fbdd208-a630-11ef-9b97-ab7a3a695ca3", + description="This block gets quote tweets for a specific tweet.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetQuoteTweetsBlock.Input, + output_schema=TwitterGetQuoteTweetsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "max_results": 2, + "pagination_token": None, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["12345", "67890"]), + ("texts", ["Tweet 1", "Tweet 2"]), + ( + "data", + [ + {"id": "12345", "text": "Tweet 1"}, + {"id": "67890", "text": "Tweet 2"}, + ], + ), + ], + test_mock={ + "get_quote_tweets": lambda *args, **kwargs: ( + ["12345", "67890"], + ["Tweet 1", "Tweet 2"], + [ + {"id": "12345", "text": "Tweet 1"}, + {"id": "67890", "text": "Tweet 2"}, + ], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_quote_tweets( + credentials: TwitterCredentials, + tweet_id: str, + max_results: int | None, + exclude: TweetExcludesFilter | None, + pagination_token: str | None, + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": tweet_id, + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "exclude": None if exclude == TweetExcludesFilter() else exclude, + "user_auth": False, + } + + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_quote_tweets(**params)) + + meta = {} + tweet_ids = [] + tweet_texts = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + tweet_ids = [str(tweet.id) for tweet in response.data] + tweet_texts = [tweet.text for tweet in response.data] + + return tweet_ids, tweet_texts, data, included, meta, next_token + + raise Exception("No quote tweets found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, texts, data, included, meta, next_token = self.get_quote_tweets( + credentials, + input_data.tweet_id, + input_data.max_results, + input_data.exclude, + input_data.pagination_token, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + if ids: + yield "ids", ids + if texts: + yield "texts", texts + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/tweets/retweet.py b/autogpt_platform/backend/backend/blocks/twitter/tweets/retweet.py new file mode 100644 index 000000000000..9b1ba81b788f --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/tweets/retweet.py @@ -0,0 +1,367 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import UserExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + TweetFieldsFilter, + TweetUserFieldsFilter, + UserExpansionInputs, + UserExpansionsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterRetweetBlock(Block): + """ + Retweets a tweet on Twitter + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "tweet.write", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet to retweet", + placeholder="Enter tweet ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField(description="Whether the retweet was successful") + error: str = SchemaField(description="Error message if the retweet failed") + + def __init__(self): + super().__init__( + id="bd7b8d3a-a630-11ef-be96-6f4aa4c3c4f4", + description="This block retweets a tweet on Twitter.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterRetweetBlock.Input, + output_schema=TwitterRetweetBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"retweet": lambda *args, **kwargs: True}, + ) + + @staticmethod + def retweet( + credentials: TwitterCredentials, + tweet_id: str, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.retweet( + tweet_id=tweet_id, + user_auth=False, + ) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.retweet( + credentials, + input_data.tweet_id, + ) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterRemoveRetweetBlock(Block): + """ + Removes a retweet on Twitter + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "tweet.write", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet to remove retweet", + placeholder="Enter tweet ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the retweet was successfully removed" + ) + error: str = SchemaField(description="Error message if the removal failed") + + def __init__(self): + super().__init__( + id="b6e663f0-a630-11ef-a7f0-8b9b0c542ff8", + description="This block removes a retweet on Twitter.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterRemoveRetweetBlock.Input, + output_schema=TwitterRemoveRetweetBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"remove_retweet": lambda *args, **kwargs: True}, + ) + + @staticmethod + def remove_retweet( + credentials: TwitterCredentials, + tweet_id: str, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.unretweet( + source_tweet_id=tweet_id, + user_auth=False, + ) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.remove_retweet( + credentials, + input_data.tweet_id, + ) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetRetweetersBlock(Block): + """ + Gets information about who has retweeted a tweet + """ + + class Input(UserExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="ID of the tweet to get retweeters for", + placeholder="Enter tweet ID", + ) + + max_results: int | None = SchemaField( + description="Maximum number of results per page (1-100)", + default=10, + placeholder="Enter max results", + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token for pagination", + placeholder="Enter pagination token", + default="", + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + ids: list = SchemaField(description="List of user ids who retweeted") + names: list = SchemaField(description="List of user names who retweeted") + usernames: list = SchemaField( + description="List of user usernames who retweeted" + ) + next_token: str = SchemaField(description="Token for next page of results") + + # Complete Outputs for advanced use + data: list[dict] = SchemaField(description="Complete Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField( + description="Provides metadata such as pagination info (next_token) or result counts" + ) + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="ad7aa6fa-a630-11ef-a6b0-e7ca640aa030", + description="This block gets information about who has retweeted a tweet.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetRetweetersBlock.Input, + output_schema=TwitterGetRetweetersBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1234567890", + "credentials": TEST_CREDENTIALS_INPUT, + "max_results": 1, + "pagination_token": "", + "expansions": None, + "media_fields": None, + "place_fields": None, + "poll_fields": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["12345"]), + ("names", ["Test User"]), + ("usernames", ["testuser"]), + ( + "data", + [{"id": "12345", "name": "Test User", "username": "testuser"}], + ), + ], + test_mock={ + "get_retweeters": lambda *args, **kwargs: ( + [{"id": "12345", "name": "Test User", "username": "testuser"}], + {}, + {}, + ["12345"], + ["Test User"], + ["testuser"], + None, + ) + }, + ) + + @staticmethod + def get_retweeters( + credentials: TwitterCredentials, + tweet_id: str, + max_results: int | None, + pagination_token: str | None, + expansions: UserExpansionsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": tweet_id, + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + UserExpansionsBuilder(params) + .add_expansions(expansions) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_retweeters(**params)) + + meta = {} + ids = [] + names = [] + usernames = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + ids = [str(user.id) for user in response.data] + names = [user.name for user in response.data] + usernames = [user.username for user in response.data] + return data, included, meta, ids, names, usernames, next_token + + raise Exception("No retweeters found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + data, included, meta, ids, names, usernames, next_token = ( + self.get_retweeters( + credentials, + input_data.tweet_id, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.tweet_fields, + input_data.user_fields, + ) + ) + + if ids: + yield "ids", ids + if names: + yield "names", names + if usernames: + yield "usernames", usernames + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/tweets/timeline.py b/autogpt_platform/backend/backend/blocks/twitter/tweets/timeline.py new file mode 100644 index 000000000000..ca89039c2eca --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/tweets/timeline.py @@ -0,0 +1,761 @@ +from datetime import datetime +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import ( + TweetDurationBuilder, + TweetExpansionsBuilder, +) +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ExpansionFilter, + TweetExpansionInputs, + TweetFieldsFilter, + TweetMediaFieldsFilter, + TweetPlaceFieldsFilter, + TweetPollFieldsFilter, + TweetTimeWindowInputs, + TweetUserFieldsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterGetUserMentionsBlock(Block): + """ + Returns Tweets where a single user is mentioned, just put that user id + """ + + class Input(TweetExpansionInputs, TweetTimeWindowInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "offline.access"] + ) + + user_id: str = SchemaField( + description="Unique identifier of the user for whom to return Tweets mentioning the user", + placeholder="Enter user ID", + ) + + max_results: int | None = SchemaField( + description="Number of tweets to retrieve (5-100)", + default=10, + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token for pagination", default="", advanced=True + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + ids: list[str] = SchemaField(description="List of Tweet IDs") + texts: list[str] = SchemaField(description="All Tweet texts") + + userIds: list[str] = SchemaField( + description="List of user ids that mentioned the user" + ) + userNames: list[str] = SchemaField( + description="List of user names that mentioned the user" + ) + next_token: str = SchemaField(description="Next token for pagination") + + # Complete Outputs for advanced use + data: list[dict] = SchemaField(description="Complete Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField( + description="Provides metadata such as pagination info (next_token) or result counts" + ) + + # error + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="e01c890c-a630-11ef-9e20-37da24888bd0", + description="This block retrieves Tweets mentioning a specific user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetUserMentionsBlock.Input, + output_schema=TwitterGetUserMentionsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "user_id": "12345", + "credentials": TEST_CREDENTIALS_INPUT, + "max_results": 2, + "start_time": "2024-12-14T18:30:00.000Z", + "end_time": "2024-12-17T18:30:00.000Z", + "since_id": "", + "until_id": "", + "sort_order": None, + "pagination_token": None, + "expansions": None, + "media_fields": None, + "place_fields": None, + "poll_fields": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["1373001119480344583", "1372627771717869568"]), + ("texts", ["Test mention 1", "Test mention 2"]), + ("userIds", ["67890", "67891"]), + ("userNames", ["testuser1", "testuser2"]), + ( + "data", + [ + {"id": "1373001119480344583", "text": "Test mention 1"}, + {"id": "1372627771717869568", "text": "Test mention 2"}, + ], + ), + ], + test_mock={ + "get_mentions": lambda *args, **kwargs: ( + ["1373001119480344583", "1372627771717869568"], + ["Test mention 1", "Test mention 2"], + ["67890", "67891"], + ["testuser1", "testuser2"], + [ + {"id": "1373001119480344583", "text": "Test mention 1"}, + {"id": "1372627771717869568", "text": "Test mention 2"}, + ], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_mentions( + credentials: TwitterCredentials, + user_id: str, + max_results: int | None, + start_time: datetime | None, + end_time: datetime | None, + since_id: str | None, + until_id: str | None, + sort_order: str | None, + pagination: str | None, + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": user_id, + "max_results": max_results, + "pagination_token": None if pagination == "" else pagination, + "user_auth": False, + } + + # Adding expansions to params If required by the user + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + # Adding time window to params If required by the user + params = ( + TweetDurationBuilder(params) + .add_start_time(start_time) + .add_end_time(end_time) + .add_since_id(since_id) + .add_until_id(until_id) + .add_sort_order(sort_order) + .build() + ) + + response = cast( + Response, + client.get_users_mentions(**params), + ) + + if not response.data and not response.meta: + raise Exception("No tweets found") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + meta = response.meta or {} + next_token = meta.get("next_token", "") + + tweet_ids = [] + tweet_texts = [] + user_ids = [] + user_names = [] + + if response.data: + tweet_ids = [str(tweet.id) for tweet in response.data] + tweet_texts = [tweet.text for tweet in response.data] + + if "users" in included: + user_ids = [str(user["id"]) for user in included["users"]] + user_names = [user["username"] for user in included["users"]] + + return ( + tweet_ids, + tweet_texts, + user_ids, + user_names, + data, + included, + meta, + next_token, + ) + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, texts, user_ids, user_names, data, included, meta, next_token = ( + self.get_mentions( + credentials, + input_data.user_id, + input_data.max_results, + input_data.start_time, + input_data.end_time, + input_data.since_id, + input_data.until_id, + input_data.sort_order, + input_data.pagination_token, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + ) + if ids: + yield "ids", ids + if texts: + yield "texts", texts + if user_ids: + yield "userIds", user_ids + if user_names: + yield "userNames", user_names + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetHomeTimelineBlock(Block): + """ + Returns a collection of the most recent Tweets and Retweets posted by you and users you follow + """ + + class Input(TweetExpansionInputs, TweetTimeWindowInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "offline.access"] + ) + + max_results: int | None = SchemaField( + description="Number of tweets to retrieve (5-100)", + default=10, + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token for pagination", default="", advanced=True + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + ids: list[str] = SchemaField(description="List of Tweet IDs") + texts: list[str] = SchemaField(description="All Tweet texts") + + userIds: list[str] = SchemaField( + description="List of user ids that authored the tweets" + ) + userNames: list[str] = SchemaField( + description="List of user names that authored the tweets" + ) + next_token: str = SchemaField(description="Next token for pagination") + + # Complete Outputs for advanced use + data: list[dict] = SchemaField(description="Complete Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField( + description="Provides metadata such as pagination info (next_token) or result counts" + ) + + # error + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="d222a070-a630-11ef-a18a-3f52f76c6962", + description="This block retrieves the authenticated user's home timeline.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetHomeTimelineBlock.Input, + output_schema=TwitterGetHomeTimelineBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "max_results": 2, + "start_time": "2024-12-14T18:30:00.000Z", + "end_time": "2024-12-17T18:30:00.000Z", + "since_id": None, + "until_id": None, + "sort_order": None, + "pagination_token": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["1373001119480344583", "1372627771717869568"]), + ("texts", ["Test tweet 1", "Test tweet 2"]), + ("userIds", ["67890", "67891"]), + ("userNames", ["testuser1", "testuser2"]), + ( + "data", + [ + {"id": "1373001119480344583", "text": "Test tweet 1"}, + {"id": "1372627771717869568", "text": "Test tweet 2"}, + ], + ), + ], + test_mock={ + "get_timeline": lambda *args, **kwargs: ( + ["1373001119480344583", "1372627771717869568"], + ["Test tweet 1", "Test tweet 2"], + ["67890", "67891"], + ["testuser1", "testuser2"], + [ + {"id": "1373001119480344583", "text": "Test tweet 1"}, + {"id": "1372627771717869568", "text": "Test tweet 2"}, + ], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_timeline( + credentials: TwitterCredentials, + max_results: int | None, + start_time: datetime | None, + end_time: datetime | None, + since_id: str | None, + until_id: str | None, + sort_order: str | None, + pagination: str | None, + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "max_results": max_results, + "pagination_token": None if pagination == "" else pagination, + "user_auth": False, + } + + # Adding expansions to params If required by the user + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + # Adding time window to params If required by the user + params = ( + TweetDurationBuilder(params) + .add_start_time(start_time) + .add_end_time(end_time) + .add_since_id(since_id) + .add_until_id(until_id) + .add_sort_order(sort_order) + .build() + ) + + response = cast( + Response, + client.get_home_timeline(**params), + ) + + if not response.data and not response.meta: + raise Exception("No tweets found") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + meta = response.meta or {} + next_token = meta.get("next_token", "") + + tweet_ids = [] + tweet_texts = [] + user_ids = [] + user_names = [] + + if response.data: + tweet_ids = [str(tweet.id) for tweet in response.data] + tweet_texts = [tweet.text for tweet in response.data] + + if "users" in included: + user_ids = [str(user["id"]) for user in included["users"]] + user_names = [user["username"] for user in included["users"]] + + return ( + tweet_ids, + tweet_texts, + user_ids, + user_names, + data, + included, + meta, + next_token, + ) + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, texts, user_ids, user_names, data, included, meta, next_token = ( + self.get_timeline( + credentials, + input_data.max_results, + input_data.start_time, + input_data.end_time, + input_data.since_id, + input_data.until_id, + input_data.sort_order, + input_data.pagination_token, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + ) + if ids: + yield "ids", ids + if texts: + yield "texts", texts + if user_ids: + yield "userIds", user_ids + if user_names: + yield "userNames", user_names + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetUserTweetsBlock(Block): + """ + Returns Tweets composed by a single user, specified by the requested user ID + """ + + class Input(TweetExpansionInputs, TweetTimeWindowInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "offline.access"] + ) + + user_id: str = SchemaField( + description="Unique identifier of the Twitter account (user ID) for whom to return results", + placeholder="Enter user ID", + ) + + max_results: int | None = SchemaField( + description="Number of tweets to retrieve (5-100)", + default=10, + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token for pagination", default="", advanced=True + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + ids: list[str] = SchemaField(description="List of Tweet IDs") + texts: list[str] = SchemaField(description="All Tweet texts") + + userIds: list[str] = SchemaField( + description="List of user ids that authored the tweets" + ) + userNames: list[str] = SchemaField( + description="List of user names that authored the tweets" + ) + next_token: str = SchemaField(description="Next token for pagination") + + # Complete Outputs for advanced use + data: list[dict] = SchemaField(description="Complete Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField( + description="Provides metadata such as pagination info (next_token) or result counts" + ) + + # error + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="c44c3ef2-a630-11ef-9ff7-eb7b5ea3a5cb", + description="This block retrieves Tweets composed by a single user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetUserTweetsBlock.Input, + output_schema=TwitterGetUserTweetsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "user_id": "12345", + "credentials": TEST_CREDENTIALS_INPUT, + "max_results": 2, + "start_time": "2024-12-14T18:30:00.000Z", + "end_time": "2024-12-17T18:30:00.000Z", + "since_id": None, + "until_id": None, + "sort_order": None, + "pagination_token": None, + "expansions": None, + "media_fields": None, + "place_fields": None, + "poll_fields": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["1373001119480344583", "1372627771717869568"]), + ("texts", ["Test tweet 1", "Test tweet 2"]), + ("userIds", ["67890", "67891"]), + ("userNames", ["testuser1", "testuser2"]), + ( + "data", + [ + {"id": "1373001119480344583", "text": "Test tweet 1"}, + {"id": "1372627771717869568", "text": "Test tweet 2"}, + ], + ), + ], + test_mock={ + "get_user_tweets": lambda *args, **kwargs: ( + ["1373001119480344583", "1372627771717869568"], + ["Test tweet 1", "Test tweet 2"], + ["67890", "67891"], + ["testuser1", "testuser2"], + [ + {"id": "1373001119480344583", "text": "Test tweet 1"}, + {"id": "1372627771717869568", "text": "Test tweet 2"}, + ], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_user_tweets( + credentials: TwitterCredentials, + user_id: str, + max_results: int | None, + start_time: datetime | None, + end_time: datetime | None, + since_id: str | None, + until_id: str | None, + sort_order: str | None, + pagination: str | None, + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": user_id, + "max_results": max_results, + "pagination_token": None if pagination == "" else pagination, + "user_auth": False, + } + + # Adding expansions to params If required by the user + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + # Adding time window to params If required by the user + params = ( + TweetDurationBuilder(params) + .add_start_time(start_time) + .add_end_time(end_time) + .add_since_id(since_id) + .add_until_id(until_id) + .add_sort_order(sort_order) + .build() + ) + + response = cast( + Response, + client.get_users_tweets(**params), + ) + + if not response.data and not response.meta: + raise Exception("No tweets found") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + meta = response.meta or {} + next_token = meta.get("next_token", "") + + tweet_ids = [] + tweet_texts = [] + user_ids = [] + user_names = [] + + if response.data: + tweet_ids = [str(tweet.id) for tweet in response.data] + tweet_texts = [tweet.text for tweet in response.data] + + if "users" in included: + user_ids = [str(user["id"]) for user in included["users"]] + user_names = [user["username"] for user in included["users"]] + + return ( + tweet_ids, + tweet_texts, + user_ids, + user_names, + data, + included, + meta, + next_token, + ) + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, texts, user_ids, user_names, data, included, meta, next_token = ( + self.get_user_tweets( + credentials, + input_data.user_id, + input_data.max_results, + input_data.start_time, + input_data.end_time, + input_data.since_id, + input_data.until_id, + input_data.sort_order, + input_data.pagination_token, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + ) + if ids: + yield "ids", ids + if texts: + yield "texts", texts + if user_ids: + yield "userIds", user_ids + if user_names: + yield "userNames", user_names + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/tweets/tweet_lookup.py b/autogpt_platform/backend/backend/blocks/twitter/tweets/tweet_lookup.py new file mode 100644 index 000000000000..5021161b9e9e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/tweets/tweet_lookup.py @@ -0,0 +1,364 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import TweetExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + ExpansionFilter, + TweetExpansionInputs, + TweetFieldsFilter, + TweetMediaFieldsFilter, + TweetPlaceFieldsFilter, + TweetPollFieldsFilter, + TweetUserFieldsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterGetTweetBlock(Block): + """ + Returns information about a single Tweet specified by the requested ID + """ + + class Input(TweetExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "offline.access"] + ) + + tweet_id: str = SchemaField( + description="Unique identifier of the Tweet to request (ex: 1460323737035677698)", + placeholder="Enter tweet ID", + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + id: str = SchemaField(description="Tweet ID") + text: str = SchemaField(description="Tweet text") + userId: str = SchemaField(description="ID of the tweet author") + userName: str = SchemaField(description="Username of the tweet author") + + # Complete Outputs for advanced use + data: dict = SchemaField(description="Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField(description="Metadata about the tweet") + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="f5155c3a-a630-11ef-9cc1-a309988b4d92", + description="This block retrieves information about a specific Tweet.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetTweetBlock.Input, + output_schema=TwitterGetTweetBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_id": "1460323737035677698", + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "media_fields": None, + "place_fields": None, + "poll_fields": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", "1460323737035677698"), + ("text", "Test tweet content"), + ("userId", "12345"), + ("userName", "testuser"), + ("data", {"id": "1460323737035677698", "text": "Test tweet content"}), + ("included", {"users": [{"id": "12345", "username": "testuser"}]}), + ("meta", {"result_count": 1}), + ], + test_mock={ + "get_tweet": lambda *args, **kwargs: ( + {"id": "1460323737035677698", "text": "Test tweet content"}, + {"users": [{"id": "12345", "username": "testuser"}]}, + {"result_count": 1}, + "12345", + "testuser", + ) + }, + ) + + @staticmethod + def get_tweet( + credentials: TwitterCredentials, + tweet_id: str, + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + params = {"id": tweet_id, "user_auth": False} + + # Adding expansions to params If required by the user + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_tweet(**params)) + + meta = {} + user_id = "" + user_name = "" + + if response.meta: + meta = response.meta + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_dict(response.data) + + if included and "users" in included: + user_id = str(included["users"][0]["id"]) + user_name = included["users"][0]["username"] + + if response.data: + return data, included, meta, user_id, user_name + + raise Exception("Tweet not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + + tweet_data, included, meta, user_id, user_name = self.get_tweet( + credentials, + input_data.tweet_id, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + + yield "id", str(tweet_data["id"]) + yield "text", tweet_data["text"] + if user_id: + yield "userId", user_id + if user_name: + yield "userName", user_name + yield "data", tweet_data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetTweetsBlock(Block): + """ + Returns information about multiple Tweets specified by the requested IDs + """ + + class Input(TweetExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["tweet.read", "users.read", "offline.access"] + ) + + tweet_ids: list[str] = SchemaField( + description="List of Tweet IDs to request (up to 100)", + placeholder="Enter tweet IDs", + ) + + class Output(BlockSchema): + # Common Outputs that user commonly uses + ids: list[str] = SchemaField(description="All Tweet IDs") + texts: list[str] = SchemaField(description="All Tweet texts") + userIds: list[str] = SchemaField( + description="List of user ids that authored the tweets" + ) + userNames: list[str] = SchemaField( + description="List of user names that authored the tweets" + ) + + # Complete Outputs for advanced use + data: list[dict] = SchemaField(description="Complete Tweet data") + included: dict = SchemaField( + description="Additional data that you have requested (Optional) via Expansions field" + ) + meta: dict = SchemaField(description="Metadata about the tweets") + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="e7cc5420-a630-11ef-bfaf-13bdd8096a51", + description="This block retrieves information about multiple Tweets.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetTweetsBlock.Input, + output_schema=TwitterGetTweetsBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "tweet_ids": ["1460323737035677698"], + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "media_fields": None, + "place_fields": None, + "poll_fields": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["1460323737035677698"]), + ("texts", ["Test tweet content"]), + ("userIds", ["67890"]), + ("userNames", ["testuser1"]), + ("data", [{"id": "1460323737035677698", "text": "Test tweet content"}]), + ("included", {"users": [{"id": "67890", "username": "testuser1"}]}), + ("meta", {"result_count": 1}), + ], + test_mock={ + "get_tweets": lambda *args, **kwargs: ( + ["1460323737035677698"], # ids + ["Test tweet content"], # texts + ["67890"], # user_ids + ["testuser1"], # user_names + [ + {"id": "1460323737035677698", "text": "Test tweet content"} + ], # data + {"users": [{"id": "67890", "username": "testuser1"}]}, # included + {"result_count": 1}, # meta + ) + }, + ) + + @staticmethod + def get_tweets( + credentials: TwitterCredentials, + tweet_ids: list[str], + expansions: ExpansionFilter | None, + media_fields: TweetMediaFieldsFilter | None, + place_fields: TweetPlaceFieldsFilter | None, + poll_fields: TweetPollFieldsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + params = {"ids": tweet_ids, "user_auth": False} + + # Adding expansions to params If required by the user + params = ( + TweetExpansionsBuilder(params) + .add_expansions(expansions) + .add_media_fields(media_fields) + .add_place_fields(place_fields) + .add_poll_fields(poll_fields) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_tweets(**params)) + + if not response.data and not response.meta: + raise Exception("No tweets found") + + tweet_ids = [] + tweet_texts = [] + user_ids = [] + user_names = [] + meta = {} + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + tweet_ids = [str(tweet.id) for tweet in response.data] + tweet_texts = [tweet.text for tweet in response.data] + + if included and "users" in included: + for user in included["users"]: + user_ids.append(str(user["id"])) + user_names.append(user["username"]) + + if response.meta: + meta = response.meta + + return tweet_ids, tweet_texts, user_ids, user_names, data, included, meta + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, texts, user_ids, user_names, data, included, meta = self.get_tweets( + credentials, + input_data.tweet_ids, + input_data.expansions, + input_data.media_fields, + input_data.place_fields, + input_data.poll_fields, + input_data.tweet_fields, + input_data.user_fields, + ) + if ids: + yield "ids", ids + if texts: + yield "texts", texts + if user_ids: + yield "userIds", user_ids + if user_names: + yield "userNames", user_names + if data: + yield "data", data + if included: + yield "included", included + if meta: + yield "meta", meta + + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/users/blocks.py b/autogpt_platform/backend/backend/blocks/twitter/users/blocks.py new file mode 100644 index 000000000000..ca118e91e2ed --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/users/blocks.py @@ -0,0 +1,177 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import UserExpansionsBuilder +from backend.blocks.twitter._serializer import IncludesSerializer +from backend.blocks.twitter._types import ( + TweetFieldsFilter, + TweetUserFieldsFilter, + UserExpansionInputs, + UserExpansionsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterGetBlockedUsersBlock(Block): + """ + Get a list of users who are blocked by the authenticating user + """ + + class Input(UserExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["users.read", "offline.access", "block.read"] + ) + + max_results: int | None = SchemaField( + description="Maximum number of results to return (1-1000, default 100)", + placeholder="Enter max results", + default=10, + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token for retrieving next/previous page of results", + placeholder="Enter pagination token", + default="", + advanced=True, + ) + + class Output(BlockSchema): + user_ids: list[str] = SchemaField(description="List of blocked user IDs") + usernames_: list[str] = SchemaField(description="List of blocked usernames") + included: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Metadata including pagination info") + next_token: str = SchemaField(description="Next token for pagination") + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="05f409e8-a631-11ef-ae89-93de863ee30d", + description="This block retrieves a list of users blocked by the authenticating user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetBlockedUsersBlock.Input, + output_schema=TwitterGetBlockedUsersBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "max_results": 10, + "pagination_token": "", + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("user_ids", ["12345", "67890"]), + ("usernames_", ["testuser1", "testuser2"]), + ], + test_mock={ + "get_blocked_users": lambda *args, **kwargs: ( + {}, # included + {}, # meta + ["12345", "67890"], # user_ids + ["testuser1", "testuser2"], # usernames + None, # next_token + ) + }, + ) + + @staticmethod + def get_blocked_users( + credentials: TwitterCredentials, + max_results: int | None, + pagination_token: str | None, + expansions: UserExpansionsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + UserExpansionsBuilder(params) + .add_expansions(expansions) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_blocked(**params)) + + meta = {} + user_ids = [] + usernames = [] + next_token = None + + included = IncludesSerializer.serialize(response.includes) + + if response.data: + for user in response.data: + user_ids.append(str(user.id)) + usernames.append(user.username) + + if response.meta: + meta = response.meta + if "next_token" in meta: + next_token = meta["next_token"] + + if user_ids and usernames: + return included, meta, user_ids, usernames, next_token + else: + raise tweepy.TweepyException("No blocked users found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + included, meta, user_ids, usernames, next_token = self.get_blocked_users( + credentials, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.tweet_fields, + input_data.user_fields, + ) + if user_ids: + yield "user_ids", user_ids + if usernames: + yield "usernames_", usernames + if included: + yield "included", included + if meta: + yield "meta", meta + if next_token: + yield "next_token", next_token + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/users/follows.py b/autogpt_platform/backend/backend/blocks/twitter/users/follows.py new file mode 100644 index 000000000000..160ffe9b3587 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/users/follows.py @@ -0,0 +1,515 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import UserExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + TweetFieldsFilter, + TweetUserFieldsFilter, + UserExpansionInputs, + UserExpansionsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterUnfollowUserBlock(Block): + """ + Allows a user to unfollow another user specified by target user ID. + The request succeeds with no action when the authenticated user sends a request to a user they're not following or have already unfollowed. + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["users.read", "users.write", "follows.write", "offline.access"] + ) + + target_user_id: str = SchemaField( + description="The user ID of the user that you would like to unfollow", + placeholder="Enter target user ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the unfollow action was successful" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="37e386a4-a631-11ef-b7bd-b78204b35fa4", + description="This block unfollows a specified Twitter user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterUnfollowUserBlock.Input, + output_schema=TwitterUnfollowUserBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "target_user_id": "12345", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"unfollow_user": lambda *args, **kwargs: True}, + ) + + @staticmethod + def unfollow_user(credentials: TwitterCredentials, target_user_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.unfollow_user(target_user_id=target_user_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.unfollow_user(credentials, input_data.target_user_id) + yield "success", success + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterFollowUserBlock(Block): + """ + Allows a user to follow another user specified by target user ID. If the target user does not have public Tweets, + this endpoint will send a follow request. The request succeeds with no action when the authenticated user sends a + request to a user they're already following, or if they're sending a follower request to a user that does not have + public Tweets. + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["users.read", "users.write", "follows.write", "offline.access"] + ) + + target_user_id: str = SchemaField( + description="The user ID of the user that you would like to follow", + placeholder="Enter target user ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the follow action was successful" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="1aae6a5e-a631-11ef-a090-435900c6d429", + description="This block follows a specified Twitter user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterFollowUserBlock.Input, + output_schema=TwitterFollowUserBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "target_user_id": "12345", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[("success", True)], + test_mock={"follow_user": lambda *args, **kwargs: True}, + ) + + @staticmethod + def follow_user(credentials: TwitterCredentials, target_user_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.follow_user(target_user_id=target_user_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.follow_user(credentials, input_data.target_user_id) + yield "success", success + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetFollowersBlock(Block): + """ + Retrieves a list of followers for a specified Twitter user ID + """ + + class Input(UserExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["users.read", "offline.access", "follows.read"] + ) + + target_user_id: str = SchemaField( + description="The user ID whose followers you would like to retrieve", + placeholder="Enter target user ID", + ) + + max_results: int | None = SchemaField( + description="Maximum number of results to return (1-1000, default 100)", + placeholder="Enter max results", + default=10, + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token for retrieving next/previous page of results", + placeholder="Enter pagination token", + default="", + advanced=True, + ) + + class Output(BlockSchema): + ids: list[str] = SchemaField(description="List of follower user IDs") + usernames: list[str] = SchemaField(description="List of follower usernames") + next_token: str = SchemaField(description="Next token for pagination") + + data: list[dict] = SchemaField(description="Complete user data for followers") + includes: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Metadata including pagination info") + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="30f66410-a631-11ef-8fe7-d7f888b4f43c", + description="This block retrieves followers of a specified Twitter user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetFollowersBlock.Input, + output_schema=TwitterGetFollowersBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "target_user_id": "12345", + "max_results": 1, + "pagination_token": "", + "expansions": None, + "tweet_fields": None, + "user_fields": None, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["1234567890"]), + ("usernames", ["testuser"]), + ("data", [{"id": "1234567890", "username": "testuser"}]), + ], + test_mock={ + "get_followers": lambda *args, **kwargs: ( + ["1234567890"], + ["testuser"], + [{"id": "1234567890", "username": "testuser"}], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_followers( + credentials: TwitterCredentials, + target_user_id: str, + max_results: int | None, + pagination_token: str | None, + expansions: UserExpansionsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": target_user_id, + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + UserExpansionsBuilder(params) + .add_expansions(expansions) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_users_followers(**params)) + + meta = {} + follower_ids = [] + follower_usernames = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + follower_ids = [str(user.id) for user in response.data] + follower_usernames = [user.username for user in response.data] + + return ( + follower_ids, + follower_usernames, + data, + included, + meta, + next_token, + ) + + raise Exception("Followers not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, usernames, data, includes, meta, next_token = self.get_followers( + credentials, + input_data.target_user_id, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.tweet_fields, + input_data.user_fields, + ) + if ids: + yield "ids", ids + if usernames: + yield "usernames", usernames + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if includes: + yield "includes", includes + if meta: + yield "meta", meta + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetFollowingBlock(Block): + """ + Retrieves a list of users that a specified Twitter user ID is following + """ + + class Input(UserExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["users.read", "offline.access", "follows.read"] + ) + + target_user_id: str = SchemaField( + description="The user ID whose following you would like to retrieve", + placeholder="Enter target user ID", + ) + + max_results: int | None = SchemaField( + description="Maximum number of results to return (1-1000, default 100)", + placeholder="Enter max results", + default=10, + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token for retrieving next/previous page of results", + placeholder="Enter pagination token", + default="", + advanced=True, + ) + + class Output(BlockSchema): + ids: list[str] = SchemaField(description="List of following user IDs") + usernames: list[str] = SchemaField(description="List of following usernames") + next_token: str = SchemaField(description="Next token for pagination") + + data: list[dict] = SchemaField(description="Complete user data for following") + includes: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Metadata including pagination info") + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="264a399c-a631-11ef-a97d-bfde4ca91173", + description="This block retrieves the users that a specified Twitter user is following.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetFollowingBlock.Input, + output_schema=TwitterGetFollowingBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "target_user_id": "12345", + "max_results": 1, + "pagination_token": None, + "expansions": None, + "tweet_fields": None, + "user_fields": None, + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["1234567890"]), + ("usernames", ["testuser"]), + ("data", [{"id": "1234567890", "username": "testuser"}]), + ], + test_mock={ + "get_following": lambda *args, **kwargs: ( + ["1234567890"], + ["testuser"], + [{"id": "1234567890", "username": "testuser"}], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_following( + credentials: TwitterCredentials, + target_user_id: str, + max_results: int | None, + pagination_token: str | None, + expansions: UserExpansionsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": target_user_id, + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + UserExpansionsBuilder(params) + .add_expansions(expansions) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_users_following(**params)) + + meta = {} + following_ids = [] + following_usernames = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + following_ids = [str(user.id) for user in response.data] + following_usernames = [user.username for user in response.data] + + return ( + following_ids, + following_usernames, + data, + included, + meta, + next_token, + ) + + raise Exception("Following not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, usernames, data, includes, meta, next_token = self.get_following( + credentials, + input_data.target_user_id, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.tweet_fields, + input_data.user_fields, + ) + if ids: + yield "ids", ids + if usernames: + yield "usernames", usernames + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if includes: + yield "includes", includes + if meta: + yield "meta", meta + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/users/mutes.py b/autogpt_platform/backend/backend/blocks/twitter/users/mutes.py new file mode 100644 index 000000000000..36bb4028f990 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/users/mutes.py @@ -0,0 +1,332 @@ +from typing import cast + +import tweepy +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import UserExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + TweetFieldsFilter, + TweetUserFieldsFilter, + UserExpansionInputs, + UserExpansionsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class TwitterUnmuteUserBlock(Block): + """ + Allows a user to unmute another user specified by target user ID. + The request succeeds with no action when the user sends a request to a user they're not muting or have already unmuted. + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["users.read", "users.write", "offline.access"] + ) + + target_user_id: str = SchemaField( + description="The user ID of the user that you would like to unmute", + placeholder="Enter target user ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the unmute action was successful" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="40458504-a631-11ef-940b-eff92be55422", + description="This block unmutes a specified Twitter user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterUnmuteUserBlock.Input, + output_schema=TwitterUnmuteUserBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "target_user_id": "12345", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"unmute_user": lambda *args, **kwargs: True}, + ) + + @staticmethod + def unmute_user(credentials: TwitterCredentials, target_user_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.unmute(target_user_id=target_user_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.unmute_user(credentials, input_data.target_user_id) + yield "success", success + + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterGetMutedUsersBlock(Block): + """ + Returns a list of users who are muted by the authenticating user + """ + + class Input(UserExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["users.read", "offline.access"] + ) + + max_results: int | None = SchemaField( + description="The maximum number of results to be returned per page (1-1000). Default is 100.", + placeholder="Enter max results", + default=10, + advanced=True, + ) + + pagination_token: str | None = SchemaField( + description="Token to request next/previous page of results", + placeholder="Enter pagination token", + default="", + advanced=True, + ) + + class Output(BlockSchema): + ids: list[str] = SchemaField(description="List of muted user IDs") + usernames: list[str] = SchemaField(description="List of muted usernames") + next_token: str = SchemaField(description="Next token for pagination") + + data: list[dict] = SchemaField(description="Complete user data for muted users") + includes: dict = SchemaField( + description="Additional data requested via expansions" + ) + meta: dict = SchemaField(description="Metadata including pagination info") + + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="475024da-a631-11ef-9ccd-f724b8b03cda", + description="This block gets a list of users muted by the authenticating user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetMutedUsersBlock.Input, + output_schema=TwitterGetMutedUsersBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "max_results": 2, + "pagination_token": "", + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["12345", "67890"]), + ("usernames", ["testuser1", "testuser2"]), + ( + "data", + [ + {"id": "12345", "username": "testuser1"}, + {"id": "67890", "username": "testuser2"}, + ], + ), + ], + test_mock={ + "get_muted_users": lambda *args, **kwargs: ( + ["12345", "67890"], + ["testuser1", "testuser2"], + [ + {"id": "12345", "username": "testuser1"}, + {"id": "67890", "username": "testuser2"}, + ], + {}, + {}, + None, + ) + }, + ) + + @staticmethod + def get_muted_users( + credentials: TwitterCredentials, + max_results: int | None, + pagination_token: str | None, + expansions: UserExpansionsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "max_results": max_results, + "pagination_token": ( + None if pagination_token == "" else pagination_token + ), + "user_auth": False, + } + + params = ( + UserExpansionsBuilder(params) + .add_expansions(expansions) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_muted(**params)) + + meta = {} + user_ids = [] + usernames = [] + next_token = None + + if response.meta: + meta = response.meta + next_token = meta.get("next_token") + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + user_ids = [str(item.id) for item in response.data] + usernames = [item.username for item in response.data] + + return user_ids, usernames, data, included, meta, next_token + + raise Exception("Muted users not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + ids, usernames, data, includes, meta, next_token = self.get_muted_users( + credentials, + input_data.max_results, + input_data.pagination_token, + input_data.expansions, + input_data.tweet_fields, + input_data.user_fields, + ) + if ids: + yield "ids", ids + if usernames: + yield "usernames", usernames + if next_token: + yield "next_token", next_token + if data: + yield "data", data + if includes: + yield "includes", includes + if meta: + yield "meta", meta + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class TwitterMuteUserBlock(Block): + """ + Allows a user to mute another user specified by target user ID + """ + + class Input(BlockSchema): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["users.read", "users.write", "offline.access"] + ) + + target_user_id: str = SchemaField( + description="The user ID of the user that you would like to mute", + placeholder="Enter target user ID", + ) + + class Output(BlockSchema): + success: bool = SchemaField( + description="Whether the mute action was successful" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="4d1919d0-a631-11ef-90ab-3b73af9ce8f1", + description="This block mutes a specified Twitter user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterMuteUserBlock.Input, + output_schema=TwitterMuteUserBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "target_user_id": "12345", + "credentials": TEST_CREDENTIALS_INPUT, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("success", True), + ], + test_mock={"mute_user": lambda *args, **kwargs: True}, + ) + + @staticmethod + def mute_user(credentials: TwitterCredentials, target_user_id: str): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + client.mute(target_user_id=target_user_id, user_auth=False) + + return True + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + success = self.mute_user(credentials, input_data.target_user_id) + yield "success", success + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/twitter/users/user_lookup.py b/autogpt_platform/backend/backend/blocks/twitter/users/user_lookup.py new file mode 100644 index 000000000000..585ebff3db48 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/twitter/users/user_lookup.py @@ -0,0 +1,386 @@ +from typing import Literal, Union, cast + +import tweepy +from pydantic import BaseModel +from tweepy.client import Response + +from backend.blocks.twitter._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + TWITTER_OAUTH_IS_CONFIGURED, + TwitterCredentials, + TwitterCredentialsField, + TwitterCredentialsInput, +) +from backend.blocks.twitter._builders import UserExpansionsBuilder +from backend.blocks.twitter._serializer import ( + IncludesSerializer, + ResponseDataSerializer, +) +from backend.blocks.twitter._types import ( + TweetFieldsFilter, + TweetUserFieldsFilter, + UserExpansionInputs, + UserExpansionsFilter, +) +from backend.blocks.twitter.tweepy_exceptions import handle_tweepy_exception +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class UserId(BaseModel): + discriminator: Literal["user_id"] + user_id: str = SchemaField(description="The ID of the user to lookup", default="") + + +class Username(BaseModel): + discriminator: Literal["username"] + username: str = SchemaField( + description="The Twitter username (handle) of the user", default="" + ) + + +class TwitterGetUserBlock(Block): + """ + Gets information about a single Twitter user specified by ID or username + """ + + class Input(UserExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["users.read", "offline.access"] + ) + + identifier: Union[UserId, Username] = SchemaField( + discriminator="discriminator", + description="Choose whether to identify the user by their unique Twitter ID or by their username", + advanced=False, + ) + + class Output(BlockSchema): + # Common outputs + id: str = SchemaField(description="User ID") + username_: str = SchemaField(description="User username") + name_: str = SchemaField(description="User name") + + # Complete outputs + data: dict = SchemaField(description="Complete user data") + included: dict = SchemaField( + description="Additional data requested via expansions" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="5446db8e-a631-11ef-812a-cf315d373ee9", + description="This block retrieves information about a specified Twitter user.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetUserBlock.Input, + output_schema=TwitterGetUserBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "identifier": {"discriminator": "username", "username": "twitter"}, + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("id", "783214"), + ("username_", "twitter"), + ("name_", "Twitter"), + ( + "data", + { + "user": { + "id": "783214", + "username": "twitter", + "name": "Twitter", + } + }, + ), + ], + test_mock={ + "get_user": lambda *args, **kwargs: ( + { + "user": { + "id": "783214", + "username": "twitter", + "name": "Twitter", + } + }, + {}, + "twitter", + "783214", + "Twitter", + ) + }, + ) + + @staticmethod + def get_user( + credentials: TwitterCredentials, + identifier: Union[UserId, Username], + expansions: UserExpansionsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "id": identifier.user_id if isinstance(identifier, UserId) else None, + "username": ( + identifier.username if isinstance(identifier, Username) else None + ), + "user_auth": False, + } + + params = ( + UserExpansionsBuilder(params) + .add_expansions(expansions) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_user(**params)) + + username = "" + id = "" + name = "" + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_dict(response.data) + + if response.data: + username = response.data.username + id = str(response.data.id) + name = response.data.name + + if username and id: + return data, included, username, id, name + else: + raise tweepy.TweepyException("User not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + data, included, username, id, name = self.get_user( + credentials, + input_data.identifier, + input_data.expansions, + input_data.tweet_fields, + input_data.user_fields, + ) + if id: + yield "id", id + if username: + yield "username_", username + if name: + yield "name_", name + if data: + yield "data", data + if included: + yield "included", included + except Exception as e: + yield "error", handle_tweepy_exception(e) + + +class UserIdList(BaseModel): + discriminator: Literal["user_id_list"] + user_ids: list[str] = SchemaField( + description="List of user IDs to lookup (max 100)", + placeholder="Enter user IDs", + default_factory=list, + advanced=False, + ) + + +class UsernameList(BaseModel): + discriminator: Literal["username_list"] + usernames: list[str] = SchemaField( + description="List of Twitter usernames/handles to lookup (max 100)", + placeholder="Enter usernames", + default_factory=list, + advanced=False, + ) + + +class TwitterGetUsersBlock(Block): + """ + Gets information about multiple Twitter users specified by IDs or usernames + """ + + class Input(UserExpansionInputs): + credentials: TwitterCredentialsInput = TwitterCredentialsField( + ["users.read", "offline.access"] + ) + + identifier: Union[UserIdList, UsernameList] = SchemaField( + discriminator="discriminator", + description="Choose whether to identify users by their unique Twitter IDs or by their usernames", + advanced=False, + ) + + class Output(BlockSchema): + # Common outputs + ids: list[str] = SchemaField(description="User IDs") + usernames_: list[str] = SchemaField(description="User usernames") + names_: list[str] = SchemaField(description="User names") + + # Complete outputs + data: list[dict] = SchemaField(description="Complete users data") + included: dict = SchemaField( + description="Additional data requested via expansions" + ) + error: str = SchemaField(description="Error message if the request failed") + + def __init__(self): + super().__init__( + id="5abc857c-a631-11ef-8cfc-f7b79354f7a1", + description="This block retrieves information about multiple Twitter users.", + categories={BlockCategory.SOCIAL}, + input_schema=TwitterGetUsersBlock.Input, + output_schema=TwitterGetUsersBlock.Output, + disabled=not TWITTER_OAUTH_IS_CONFIGURED, + test_input={ + "identifier": { + "discriminator": "username_list", + "usernames": ["twitter", "twitterdev"], + }, + "credentials": TEST_CREDENTIALS_INPUT, + "expansions": None, + "tweet_fields": None, + "user_fields": None, + }, + test_credentials=TEST_CREDENTIALS, + test_output=[ + ("ids", ["783214", "2244994945"]), + ("usernames_", ["twitter", "twitterdev"]), + ("names_", ["Twitter", "Twitter Dev"]), + ( + "data", + [ + {"id": "783214", "username": "twitter", "name": "Twitter"}, + { + "id": "2244994945", + "username": "twitterdev", + "name": "Twitter Dev", + }, + ], + ), + ], + test_mock={ + "get_users": lambda *args, **kwargs: ( + [ + {"id": "783214", "username": "twitter", "name": "Twitter"}, + { + "id": "2244994945", + "username": "twitterdev", + "name": "Twitter Dev", + }, + ], + {}, + ["twitter", "twitterdev"], + ["783214", "2244994945"], + ["Twitter", "Twitter Dev"], + ) + }, + ) + + @staticmethod + def get_users( + credentials: TwitterCredentials, + identifier: Union[UserIdList, UsernameList], + expansions: UserExpansionsFilter | None, + tweet_fields: TweetFieldsFilter | None, + user_fields: TweetUserFieldsFilter | None, + ): + try: + client = tweepy.Client( + bearer_token=credentials.access_token.get_secret_value() + ) + + params = { + "ids": ( + ",".join(identifier.user_ids) + if isinstance(identifier, UserIdList) + else None + ), + "usernames": ( + ",".join(identifier.usernames) + if isinstance(identifier, UsernameList) + else None + ), + "user_auth": False, + } + + params = ( + UserExpansionsBuilder(params) + .add_expansions(expansions) + .add_tweet_fields(tweet_fields) + .add_user_fields(user_fields) + .build() + ) + + response = cast(Response, client.get_users(**params)) + + usernames = [] + ids = [] + names = [] + + included = IncludesSerializer.serialize(response.includes) + data = ResponseDataSerializer.serialize_list(response.data) + + if response.data: + for user in response.data: + usernames.append(user.username) + ids.append(str(user.id)) + names.append(user.name) + + if usernames and ids: + return data, included, usernames, ids, names + else: + raise tweepy.TweepyException("Users not found") + + except tweepy.TweepyException: + raise + + async def run( + self, + input_data: Input, + *, + credentials: TwitterCredentials, + **kwargs, + ) -> BlockOutput: + try: + data, included, usernames, ids, names = self.get_users( + credentials, + input_data.identifier, + input_data.expansions, + input_data.tweet_fields, + input_data.user_fields, + ) + if ids: + yield "ids", ids + if usernames: + yield "usernames_", usernames + if names: + yield "names_", names + if data: + yield "data", data + if included: + yield "included", included + except Exception as e: + yield "error", handle_tweepy_exception(e) diff --git a/autogpt_platform/backend/backend/blocks/wolfram/__init__.py b/autogpt_platform/backend/backend/blocks/wolfram/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/blocks/wolfram/_api.py b/autogpt_platform/backend/backend/blocks/wolfram/_api.py new file mode 100644 index 000000000000..8c76542b1f1e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/wolfram/_api.py @@ -0,0 +1,14 @@ +from backend.sdk import APIKeyCredentials, Requests + + +async def llm_api_call(credentials: APIKeyCredentials, question: str) -> str: + params = {"appid": credentials.api_key.get_secret_value(), "input": question} + response = await Requests().get( + "https://www.wolframalpha.com/api/v1/llm-api", params=params + ) + if not response.ok: + raise ValueError(f"API request failed: {response.status} {response.text()}") + + answer = response.text() if response.text() else "" + + return answer diff --git a/autogpt_platform/backend/backend/blocks/wolfram/llm_api.py b/autogpt_platform/backend/backend/blocks/wolfram/llm_api.py new file mode 100644 index 000000000000..5586e0d8ef29 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/wolfram/llm_api.py @@ -0,0 +1,50 @@ +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockCostType, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + ProviderBuilder, + SchemaField, +) + +from ._api import llm_api_call + +wolfram = ( + ProviderBuilder("wolfram") + .with_api_key("WOLFRAM_APP_ID", "Wolfram Alpha App ID") + .with_base_cost(1, BlockCostType.RUN) + .build() +) + + +class AskWolframBlock(Block): + """ + Ask Wolfram Alpha a question. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = wolfram.credentials_field( + description="Wolfram Alpha API credentials" + ) + question: str = SchemaField(description="The question to ask") + + class Output(BlockSchema): + answer: str = SchemaField(description="The answer to the question") + + def __init__(self): + super().__init__( + id="b7710ce4-68ef-4e82-9a2f-f0b874ef9c7d", + description="Ask Wolfram Alpha a question", + categories={BlockCategory.SEARCH}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + answer = await llm_api_call(credentials, input_data.question) + yield "answer", answer diff --git a/autogpt_platform/backend/backend/blocks/wordpress/__init__.py b/autogpt_platform/backend/backend/blocks/wordpress/__init__.py new file mode 100644 index 000000000000..c7b1e26eeaa8 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/wordpress/__init__.py @@ -0,0 +1,3 @@ +from .blog import WordPressCreatePostBlock + +__all__ = ["WordPressCreatePostBlock"] diff --git a/autogpt_platform/backend/backend/blocks/wordpress/_api.py b/autogpt_platform/backend/backend/blocks/wordpress/_api.py new file mode 100644 index 000000000000..070dbd898b8c --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/wordpress/_api.py @@ -0,0 +1,498 @@ +from datetime import datetime +from enum import Enum +from logging import getLogger +from typing import Any, Dict, List, Union +from urllib.parse import urlencode + +from backend.sdk import BaseModel, Credentials, Requests + +logger = getLogger(__name__) + +WORDPRESS_BASE_URL = "https://public-api.wordpress.com/" + + +class OAuthAuthorizeRequest(BaseModel): + """OAuth authorization request parameters for WordPress. + + Parameters: + client_id: Your application's client ID from WordPress.com + redirect_uri: The URI for the authorize response redirect. Must exactly match a redirect URI + associated with your application. + response_type: Can be "code" or "token". "code" should be used for server side applications. + scope: A space delimited list of scopes. Optional, defaults to single blog access. + blog: Optional blog parameter with the URL or blog ID for a WordPress.com blog or Jetpack site. + """ + + client_id: str + redirect_uri: str + response_type: str = "code" + scope: str | None = None + blog: str | None = None + + +class OAuthTokenRequest(BaseModel): + """OAuth token request parameters for WordPress. + + These parameters must be formatted via application/x-www-form-urlencoded encoding. + + Parameters: + code: The grant code returned in the redirect. Can only be used once. + client_id: Your application's client ID. + redirect_uri: The redirect_uri used in the authorization request. + client_secret: Your application's client secret. + grant_type: The string "authorization_code". + """ + + code: str + client_id: str + redirect_uri: str + client_secret: str + grant_type: str = "authorization_code" + + +class OAuthRefreshTokenRequest(BaseModel): + """OAuth token refresh request parameters for WordPress. + + Note: WordPress OAuth2 tokens do not expire when using the "code" response type, + so refresh tokens are typically not needed for server-side applications. + + Parameters: + refresh_token: The saved refresh token from the previous token grant. + client_id: Your application's client ID. + client_secret: Your application's client secret. + grant_type: The string "refresh_token". + """ + + refresh_token: str + client_id: str + client_secret: str + grant_type: str = "refresh_token" + + +class OAuthTokenResponse(BaseModel): + """OAuth token response from WordPress. + + Successful response has HTTP status code 200 (OK). + + Parameters: + access_token: An opaque string. Can be used to make requests to the WordPress API on behalf + of the user. + blog_id: The ID of the authorized blog. + blog_url: The URL of the authorized blog. + token_type: The string "bearer". + scope: Optional field for global tokens containing the granted scopes. + refresh_token: Optional refresh token (typically not provided for server-side apps). + expires_in: Optional expiration time (tokens from code flow don't expire). + """ + + access_token: str + blog_id: str | None = None + blog_url: str | None = None + token_type: str = "bearer" + scope: str | None = None + refresh_token: str | None = None + expires_in: int | None = None + + +def make_oauth_authorize_url( + client_id: str, + redirect_uri: str, + scopes: list[str] | None = None, +) -> str: + """ + Generate the OAuth authorization URL for WordPress. + + Args: + client_id: Your application's client ID from WordPress.com + redirect_uri: The URI for the authorize response redirect + scopes: Optional list of scopes. Defaults to single blog access if not provided. + blog: Optional blog URL or ID for a WordPress.com blog or Jetpack site. + + Returns: + The authorization URL that the user should visit + """ + # Build request parameters + params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + } + + if scopes: + params["scope"] = " ".join(scopes) + + # Build the authorization URL + base_url = f"{WORDPRESS_BASE_URL}oauth2/authorize" + query_string = urlencode(params) + + return f"{base_url}?{query_string}" + + +async def oauth_exchange_code_for_tokens( + client_id: str, + client_secret: str, + code: str, + redirect_uri: str, +) -> OAuthTokenResponse: + """ + Exchange an authorization code for access token. + + Args: + client_id: Your application's client ID. + client_secret: Your application's client secret. + code: The authorization code returned by WordPress. + redirect_uri: The redirect URI used during authorization. + + Returns: + Parsed JSON response containing the access token, blog info, etc. + """ + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + + data = OAuthTokenRequest( + code=code, + client_id=client_id, + client_secret=client_secret, + redirect_uri=redirect_uri, + grant_type="authorization_code", + ).model_dump(exclude_none=True) + + response = await Requests().post( + f"{WORDPRESS_BASE_URL}oauth2/token", + headers=headers, + data=data, + ) + + if response.ok: + return OAuthTokenResponse.model_validate(response.json()) + raise ValueError( + f"Failed to exchange code for tokens: {response.status} {response.text}" + ) + + +async def oauth_refresh_tokens( + client_id: str, + client_secret: str, + refresh_token: str, +) -> OAuthTokenResponse: + """ + Refresh an expired access token (for implicit/client-side tokens only). + + Note: Tokens obtained via the "code" flow for server-side applications do not expire. + This is primarily used for client-side applications using implicit OAuth. + + Args: + client_id: Your application's client ID. + client_secret: Your application's client secret. + refresh_token: The refresh token previously issued by WordPress. + + Returns: + Parsed JSON response containing the new access token. + """ + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + + data = OAuthRefreshTokenRequest( + refresh_token=refresh_token, + client_id=client_id, + client_secret=client_secret, + grant_type="refresh_token", + ).model_dump(exclude_none=True) + + response = await Requests().post( + f"{WORDPRESS_BASE_URL}oauth2/token", + headers=headers, + data=data, + ) + + if response.ok: + return OAuthTokenResponse.model_validate(response.json()) + raise ValueError(f"Failed to refresh tokens: {response.status} {response.text}") + + +class TokenInfoResponse(BaseModel): + """Token validation response from WordPress. + + Parameters: + client_id: Your application's client ID. + user_id: The WordPress.com user ID. + blog_id: The blog ID associated with the token. + scope: The scope of the token. + """ + + client_id: str + user_id: str + blog_id: str | None = None + scope: str | None = None + + +async def validate_token( + client_id: str, + token: str, +) -> TokenInfoResponse: + """ + Validate an access token and get associated metadata. + + Args: + client_id: Your application's client ID. + token: The access token to validate. + + Returns: + Token info including user ID, blog ID, and scope. + """ + + params = { + "client_id": client_id, + "token": token, + } + + response = await Requests().get( + f"{WORDPRESS_BASE_URL}oauth2/token-info", + params=params, + ) + + if response.ok: + return TokenInfoResponse.model_validate(response.json()) + raise ValueError(f"Invalid token: {response.status} {response.text}") + + +async def make_api_request( + endpoint: str, + access_token: str, + method: str = "GET", + data: dict[str, Any] | None = None, + params: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ + Make an authenticated API request to WordPress. + + Args: + endpoint: The API endpoint (e.g., "/rest/v1/me/", "/rest/v1/sites/{site_id}/posts/new") + access_token: The OAuth access token + method: HTTP method (GET, POST, etc.) + data: Request body data for POST/PUT requests + params: Query parameters + + Returns: + JSON response from the API + """ + + headers = { + "Authorization": f"Bearer {access_token}", + } + + if data and method in ["POST", "PUT", "PATCH"]: + headers["Content-Type"] = "application/json" + + # Ensure endpoint starts with / + if not endpoint.startswith("/"): + endpoint = f"/{endpoint}" + + url = f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}" + + request_method = getattr(Requests(), method.lower()) + response = await request_method( + url, + headers=headers, + json=data if method in ["POST", "PUT", "PATCH"] else None, + params=params, + ) + + if response.ok: + return response.json() + raise ValueError(f"API request failed: {response.status} {response.text}") + + +# Post-related models and functions + + +class PostStatus(str, Enum): + """WordPress post status options.""" + + PUBLISH = "publish" + PRIVATE = "private" + DRAFT = "draft" + PENDING = "pending" + FUTURE = "future" + AUTO_DRAFT = "auto-draft" + + +class PostFormat(str, Enum): + """WordPress post format options.""" + + STANDARD = "standard" + ASIDE = "aside" + CHAT = "chat" + GALLERY = "gallery" + LINK = "link" + IMAGE = "image" + QUOTE = "quote" + STATUS = "status" + VIDEO = "video" + AUDIO = "audio" + + +class CreatePostRequest(BaseModel): + """Request model for creating a WordPress post. + + All fields are optional except those you want to set. + """ + + # Basic content + title: str | None = None + content: str | None = None + excerpt: str | None = None + + # Post metadata + date: datetime | None = None + slug: str | None = None + author: str | None = None + status: PostStatus | None = PostStatus.PUBLISH + password: str | None = None + sticky: bool | None = False + + # Organization + parent: int | None = None + type: str | None = "post" + categories: List[str] | None = None + tags: List[str] | None = None + format: PostFormat | None = None + + # Media + featured_image: str | None = None + media_urls: List[str] | None = None + + # Sharing + publicize: bool | None = True + publicize_message: str | None = None + + # Engagement + likes_enabled: bool | None = None + sharing_enabled: bool | None = True + discussion: Dict[str, bool] | None = None + + # Page-specific + menu_order: int | None = None + page_template: str | None = None + + # Advanced + metadata: List[Dict[str, Any]] | None = None + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} + + +class PostAuthor(BaseModel): + """Author information in post response.""" + + ID: int + login: str + email: Union[str, bool, None] = None + name: str + nice_name: str + URL: str | None = None + avatar_URL: str | None = None + + +class PostResponse(BaseModel): + """Response model for a WordPress post.""" + + ID: int + site_ID: int + author: PostAuthor + date: datetime + modified: datetime + title: str + URL: str + short_URL: str + content: str + excerpt: str + slug: str + guid: str + status: str + sticky: bool + password: str | None = "" + parent: Union[Dict[str, Any], bool, None] = None + type: str + discussion: Dict[str, Union[str, bool, int]] + likes_enabled: bool + sharing_enabled: bool + like_count: int + i_like: bool + is_reblogged: bool + is_following: bool + global_ID: str + featured_image: str | None = None + post_thumbnail: Dict[str, Any] | None = None + format: str + geo: Union[Dict[str, Any], bool, None] = None + menu_order: int | None = None + page_template: str | None = None + publicize_URLs: List[str] + terms: Dict[str, Dict[str, Any]] + tags: Dict[str, Dict[str, Any]] + categories: Dict[str, Dict[str, Any]] + attachments: Dict[str, Dict[str, Any]] + attachment_count: int + metadata: List[Dict[str, Any]] + meta: Dict[str, Any] + capabilities: Dict[str, bool] + revisions: List[int] | None = None + other_URLs: Dict[str, Any] | None = None + + +async def create_post( + credentials: Credentials, + site: str, + post_data: CreatePostRequest, +) -> PostResponse: + """ + Create a new post on a WordPress site. + + Args: + site: Site ID or domain (e.g., "myblog.wordpress.com" or "123456789") + access_token: OAuth access token + post_data: Post data using CreatePostRequest model + + Returns: + PostResponse with the created post details + """ + + # Convert the post data to a dictionary, excluding None values + data = post_data.model_dump(exclude_none=True) + + # Handle special fields that need conversion + if "categories" in data and isinstance(data["categories"], list): + data["categories"] = ",".join(str(c) for c in data["categories"]) + + if "tags" in data and isinstance(data["tags"], list): + data["tags"] = ",".join(str(t) for t in data["tags"]) + + # Make the API request + endpoint = f"/rest/v1.1/sites/{site}/posts/new" + + headers = { + "Authorization": credentials.auth_header(), + "Content-Type": "application/x-www-form-urlencoded", + } + + response = await Requests().post( + f"{WORDPRESS_BASE_URL.rstrip('/')}{endpoint}", + headers=headers, + data=data, + ) + + if response.ok: + return PostResponse.model_validate(response.json()) + + error_data = ( + response.json() + if response.headers.get("content-type", "").startswith("application/json") + else {} + ) + error_message = error_data.get("message", response.text) + raise ValueError(f"Failed to create post: {response.status} - {error_message}") diff --git a/autogpt_platform/backend/backend/blocks/wordpress/_config.py b/autogpt_platform/backend/backend/blocks/wordpress/_config.py new file mode 100644 index 000000000000..64cd8bdb758c --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/wordpress/_config.py @@ -0,0 +1,20 @@ +from backend.sdk import BlockCostType, ProviderBuilder + +from ._oauth import WordPressOAuthHandler, WordPressScope + +wordpress = ( + ProviderBuilder("wordpress") + .with_base_cost(1, BlockCostType.RUN) + .with_oauth( + WordPressOAuthHandler, + scopes=[ + v.value + for v in [ + WordPressScope.POSTS, + ] + ], + client_id_env_var="WORDPRESS_CLIENT_ID", + client_secret_env_var="WORDPRESS_CLIENT_SECRET", + ) + .build() +) diff --git a/autogpt_platform/backend/backend/blocks/wordpress/_oauth.py b/autogpt_platform/backend/backend/blocks/wordpress/_oauth.py new file mode 100644 index 000000000000..7dfb74107a51 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/wordpress/_oauth.py @@ -0,0 +1,214 @@ +import time +from enum import Enum +from logging import getLogger +from typing import Optional +from urllib.parse import quote + +from backend.sdk import BaseOAuthHandler, OAuth2Credentials, ProviderName, SecretStr + +from ._api import ( + OAuthTokenResponse, + TokenInfoResponse, + make_oauth_authorize_url, + oauth_exchange_code_for_tokens, + oauth_refresh_tokens, + validate_token, +) + +logger = getLogger(__name__) + + +class WordPressScope(str, Enum): + """WordPress OAuth2 scopes. + + Note: If no scope is specified, the token will grant full access to a single blog. + Special scopes: + - auth: Access to /me endpoints only, primarily for WordPress.com Connect + - global: Full access to all blogs the user has on WordPress.com + """ + + # Common endpoint-specific scopes + POSTS = "posts" + COMMENTS = "comments" + LIKES = "likes" + FOLLOW = "follow" + STATS = "stats" + USERS = "users" + SITES = "sites" + MEDIA = "media" + + # Special scopes + AUTH = "auth" # Access to /me endpoints only + GLOBAL = "global" # Full access to all user's blogs + + +class WordPressOAuthHandler(BaseOAuthHandler): + """ + OAuth2 handler for WordPress.com and Jetpack sites. + + Supports both single blog and global access tokens. + Server-side tokens (using 'code' response type) do not expire. + """ + + PROVIDER_NAME = ProviderName("wordpress") + # Default to no scopes for single blog access + DEFAULT_SCOPES = [] + + def __init__(self, client_id: str, client_secret: Optional[str], redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.scopes = self.DEFAULT_SCOPES + + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] = None + ) -> str: + logger.debug("Generating WordPress OAuth login URL") + # WordPress doesn't require PKCE, so code_challenge is not used + if not scopes: + logger.debug("No scopes provided, will default to single blog access") + scopes = self.scopes + + logger.debug(f"Using scopes: {scopes}") + logger.debug(f"State: {state}") + + try: + base_url = make_oauth_authorize_url( + self.client_id, self.redirect_uri, scopes if scopes else None + ) + + separator = "&" if "?" in base_url else "?" + url = f"{base_url}{separator}state={quote(state)}" + logger.debug(f"Generated OAuth URL: {url}") + return url + except Exception as e: + logger.error(f"Failed to generate OAuth URL: {str(e)}") + raise + + async def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] = None + ) -> OAuth2Credentials: + logger.debug("Exchanging authorization code for tokens") + logger.debug(f"Code: {code[:4]}...") + logger.debug(f"Scopes: {scopes}") + + # WordPress doesn't use PKCE, so code_verifier is not needed + + try: + response: OAuthTokenResponse = await oauth_exchange_code_for_tokens( + client_id=self.client_id, + client_secret=self.client_secret if self.client_secret else "", + code=code, + redirect_uri=self.redirect_uri, + ) + logger.info("Successfully exchanged code for tokens") + + # Store blog info in metadata + metadata = {} + if response.blog_id: + metadata["blog_id"] = response.blog_id + if response.blog_url: + metadata["blog_url"] = response.blog_url + + # WordPress tokens from code flow don't expire + credentials = OAuth2Credentials( + access_token=SecretStr(response.access_token), + refresh_token=( + SecretStr(response.refresh_token) + if response.refresh_token + else None + ), + access_token_expires_at=None, + refresh_token_expires_at=None, + provider=self.PROVIDER_NAME, + scopes=scopes if scopes else [], + metadata=metadata, + ) + + if response.expires_in: + logger.debug( + f"Token expires in {response.expires_in} seconds (client-side token)" + ) + else: + logger.debug("Token does not expire (server-side token)") + + return credentials + + except Exception as e: + logger.error(f"Failed to exchange code for tokens: {str(e)}") + raise + + async def _refresh_tokens( + self, credentials: OAuth2Credentials + ) -> OAuth2Credentials: + """ + Added for completeness, as WordPress tokens don't expire + """ + + logger.debug("Attempting to refresh OAuth tokens") + + # Server-side tokens don't expire + if credentials.access_token_expires_at is None: + logger.info("Token does not expire (server-side token), no refresh needed") + return credentials + + if credentials.refresh_token is None: + logger.error("Cannot refresh tokens - no refresh token available") + raise ValueError("No refresh token available") + + try: + response: OAuthTokenResponse = await oauth_refresh_tokens( + client_id=self.client_id, + client_secret=self.client_secret if self.client_secret else "", + refresh_token=credentials.refresh_token.get_secret_value(), + ) + logger.info("Successfully refreshed tokens") + + # Preserve blog info from original credentials + metadata = credentials.metadata or {} + if response.blog_id: + metadata["blog_id"] = response.blog_id + if response.blog_url: + metadata["blog_url"] = response.blog_url + + new_credentials = OAuth2Credentials( + access_token=SecretStr(response.access_token), + refresh_token=( + SecretStr(response.refresh_token) + if response.refresh_token + else credentials.refresh_token + ), + access_token_expires_at=( + int(time.time()) + response.expires_in + if response.expires_in + else None + ), + refresh_token_expires_at=None, + provider=self.PROVIDER_NAME, + scopes=credentials.scopes, + metadata=metadata, + ) + + if response.expires_in: + logger.debug( + f"New access token expires in {response.expires_in} seconds" + ) + else: + logger.debug("New token does not expire") + + return new_credentials + + except Exception as e: + logger.error(f"Failed to refresh tokens: {str(e)}") + raise + + async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + logger.debug("Token revocation requested") + logger.info( + "WordPress doesn't provide a token revocation endpoint - server-side tokens don't expire" + ) + return False + + async def validate_access_token(self, token: str) -> TokenInfoResponse: + """Validate an access token and get associated metadata.""" + return await validate_token(self.client_id, token) diff --git a/autogpt_platform/backend/backend/blocks/wordpress/blog.py b/autogpt_platform/backend/backend/blocks/wordpress/blog.py new file mode 100644 index 000000000000..5474b7afda28 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/wordpress/blog.py @@ -0,0 +1,92 @@ +from backend.sdk import ( + Block, + BlockCategory, + BlockOutput, + BlockSchema, + Credentials, + CredentialsMetaInput, + SchemaField, +) + +from ._api import CreatePostRequest, PostResponse, PostStatus, create_post +from ._config import wordpress + + +class WordPressCreatePostBlock(Block): + """ + Creates a new post on a WordPress.com site or Jetpack-enabled site and publishes it. + """ + + class Input(BlockSchema): + credentials: CredentialsMetaInput = wordpress.credentials_field() + site: str = SchemaField( + description="Site ID or domain (e.g., 'myblog.wordpress.com' or '123456789')" + ) + title: str = SchemaField(description="The post title") + content: str = SchemaField(description="The post content (HTML supported)") + excerpt: str | None = SchemaField( + description="An optional post excerpt/summary", default=None + ) + slug: str | None = SchemaField( + description="The URL slug for the post (auto-generated if not provided)", + default=None, + ) + author: str | None = SchemaField( + description="Username or ID of the author (defaults to authenticated user)", + default=None, + ) + categories: list[str] = SchemaField( + description="List of category names or IDs", default=[] + ) + tags: list[str] = SchemaField( + description="List of tag names or IDs", default=[] + ) + featured_image: str | None = SchemaField( + description="Post ID of an existing attachment to set as featured image", + default=None, + ) + media_urls: list[str] = SchemaField( + description="URLs of images to sideload and attach to the post", default=[] + ) + + class Output(BlockSchema): + post_id: int = SchemaField(description="The ID of the created post") + post_url: str = SchemaField(description="The full URL of the created post") + short_url: str = SchemaField(description="The shortened wp.me URL") + post_data: dict = SchemaField(description="Complete post data returned by API") + + def __init__(self): + super().__init__( + id="ee4fe08c-18f9-442f-a985-235379b932e1", + description="Create a new post on WordPress.com or Jetpack sites", + categories={BlockCategory.SOCIAL}, + input_schema=self.Input, + output_schema=self.Output, + ) + + async def run( + self, input_data: Input, *, credentials: Credentials, **kwargs + ) -> BlockOutput: + post_request = CreatePostRequest( + title=input_data.title, + content=input_data.content, + excerpt=input_data.excerpt, + slug=input_data.slug, + author=input_data.author, + categories=input_data.categories, + tags=input_data.tags, + featured_image=input_data.featured_image, + media_urls=input_data.media_urls, + status=PostStatus.PUBLISH, + ) + + post_response: PostResponse = await create_post( + credentials=credentials, + site=input_data.site, + post_data=post_request, + ) + + yield "post_id", post_response.ID + yield "post_url", post_response.URL + yield "short_url", post_response.short_URL + yield "post_data", post_response.model_dump() diff --git a/autogpt_platform/backend/backend/blocks/xml_parser.py b/autogpt_platform/backend/backend/blocks/xml_parser.py new file mode 100644 index 000000000000..a3d58544994e --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/xml_parser.py @@ -0,0 +1,37 @@ +from gravitasml.parser import Parser +from gravitasml.token import tokenize + +from backend.data.block import Block, BlockOutput, BlockSchema +from backend.data.model import SchemaField + + +class XMLParserBlock(Block): + class Input(BlockSchema): + input_xml: str = SchemaField(description="input xml to be parsed") + + class Output(BlockSchema): + parsed_xml: dict = SchemaField(description="output parsed xml to dict") + error: str = SchemaField(description="Error in parsing") + + def __init__(self): + super().__init__( + id="286380af-9529-4b55-8be0-1d7c854abdb5", + description="Parses XML using gravitasml to tokenize and coverts it to dict", + input_schema=XMLParserBlock.Input, + output_schema=XMLParserBlock.Output, + test_input={"input_xml": "content"}, + test_output=[ + ("parsed_xml", {"tag1": {"tag2": "content"}}), + ], + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + try: + tokens = tokenize(input_data.input_xml) + parser = Parser(tokens) + parsed_result = parser.parse() + yield "parsed_xml", parsed_result + except ValueError as val_e: + raise ValueError(f"Validation error for dict:{val_e}") from val_e + except SyntaxError as syn_e: + raise SyntaxError(f"Error in input xml syntax: {syn_e}") from syn_e diff --git a/autogpt_platform/backend/backend/blocks/youtube.py b/autogpt_platform/backend/backend/blocks/youtube.py index 648d9d6dae07..bb8c61449ee6 100644 --- a/autogpt_platform/backend/backend/blocks/youtube.py +++ b/autogpt_platform/backend/backend/blocks/youtube.py @@ -1,6 +1,7 @@ from urllib.parse import parse_qs, urlparse -from youtube_transcript_api import YouTubeTranscriptApi +from youtube_transcript_api._api import YouTubeTranscriptApi +from youtube_transcript_api._transcripts import FetchedTranscript from youtube_transcript_api.formatters import TextFormatter from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema @@ -42,6 +43,7 @@ def __init__(self): {"text": "Never gonna give you up"}, {"text": "Never gonna let you down"}, ], + "format_transcript": lambda transcript: "Never gonna give you up\nNever gonna let you down", }, ) @@ -61,30 +63,20 @@ def extract_video_id(url: str) -> str: raise ValueError(f"Invalid YouTube URL: {url}") @staticmethod - def get_transcript(video_id: str): - try: - transcript_list = YouTubeTranscriptApi.list_transcripts(video_id) + def get_transcript(video_id: str) -> FetchedTranscript: + return YouTubeTranscriptApi().fetch(video_id=video_id) - if not transcript_list: - raise ValueError(f"No transcripts found for the video: {video_id}") - - for transcript in transcript_list: - first_transcript = transcript_list.find_transcript( - [transcript.language_code] - ) - return YouTubeTranscriptApi.get_transcript( - video_id, languages=[first_transcript.language_code] - ) - - except Exception: - raise ValueError(f"No transcripts found for the video: {video_id}") + @staticmethod + def format_transcript(transcript: FetchedTranscript) -> str: + formatter = TextFormatter() + transcript_text = formatter.format_transcript(transcript) + return transcript_text - def run(self, input_data: Input, **kwargs) -> BlockOutput: + async def run(self, input_data: Input, **kwargs) -> BlockOutput: video_id = self.extract_video_id(input_data.youtube_url) yield "video_id", video_id transcript = self.get_transcript(video_id) - formatter = TextFormatter() - transcript_text = formatter.format_transcript(transcript) + transcript_text = self.format_transcript(transcript=transcript) yield "transcript", transcript_text diff --git a/autogpt_platform/backend/backend/blocks/zerobounce/_api.py b/autogpt_platform/backend/backend/blocks/zerobounce/_api.py new file mode 100644 index 000000000000..c78fe38c0905 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/zerobounce/_api.py @@ -0,0 +1,10 @@ +from zerobouncesdk import ZBValidateResponse, ZeroBounce + + +class ZeroBounceClient: + def __init__(self, api_key: str): + self.api_key = api_key + self.client = ZeroBounce(api_key) + + def validate_email(self, email: str, ip_address: str) -> ZBValidateResponse: + return self.client.validate(email, ip_address) diff --git a/autogpt_platform/backend/backend/blocks/zerobounce/_auth.py b/autogpt_platform/backend/backend/blocks/zerobounce/_auth.py new file mode 100644 index 000000000000..e7125fc3c9db --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/zerobounce/_auth.py @@ -0,0 +1,35 @@ +from typing import Literal + +from pydantic import SecretStr + +from backend.data.model import APIKeyCredentials, CredentialsField, CredentialsMetaInput +from backend.integrations.providers import ProviderName + +ZeroBounceCredentials = APIKeyCredentials +ZeroBounceCredentialsInput = CredentialsMetaInput[ + Literal[ProviderName.ZEROBOUNCE], + Literal["api_key"], +] + +TEST_CREDENTIALS = APIKeyCredentials( + id="01234567-89ab-cdef-0123-456789abcdef", + provider="zerobounce", + api_key=SecretStr("mock-zerobounce-api-key"), + title="Mock ZeroBounce API key", + expires_at=None, +) +TEST_CREDENTIALS_INPUT = { + "provider": TEST_CREDENTIALS.provider, + "id": TEST_CREDENTIALS.id, + "type": TEST_CREDENTIALS.type, + "title": TEST_CREDENTIALS.title, +} + + +def ZeroBounceCredentialsField() -> ZeroBounceCredentialsInput: + """ + Creates a ZeroBounce credentials input on a block. + """ + return CredentialsField( + description="The ZeroBounce integration can be used with an API Key.", + ) diff --git a/autogpt_platform/backend/backend/blocks/zerobounce/validate_emails.py b/autogpt_platform/backend/backend/blocks/zerobounce/validate_emails.py new file mode 100644 index 000000000000..6bb96d0a8f87 --- /dev/null +++ b/autogpt_platform/backend/backend/blocks/zerobounce/validate_emails.py @@ -0,0 +1,175 @@ +from typing import Optional + +from pydantic import BaseModel +from zerobouncesdk.zb_validate_response import ( + ZBValidateResponse, + ZBValidateStatus, + ZBValidateSubStatus, +) + +from backend.blocks.zerobounce._api import ZeroBounceClient +from backend.blocks.zerobounce._auth import ( + TEST_CREDENTIALS, + TEST_CREDENTIALS_INPUT, + ZeroBounceCredentials, + ZeroBounceCredentialsInput, +) +from backend.data.block import Block, BlockCategory, BlockOutput, BlockSchema +from backend.data.model import CredentialsField, SchemaField + + +class Response(BaseModel): + address: str = SchemaField( + description="The email address you are validating.", default="N/A" + ) + status: ZBValidateStatus = SchemaField( + description="The status of the email address.", default=ZBValidateStatus.unknown + ) + sub_status: ZBValidateSubStatus = SchemaField( + description="The sub-status of the email address.", + default=ZBValidateSubStatus.none, + ) + account: Optional[str] = SchemaField( + description="The portion of the email address before the '@' symbol.", + default="N/A", + ) + domain: Optional[str] = SchemaField( + description="The portion of the email address after the '@' symbol." + ) + did_you_mean: Optional[str] = SchemaField( + description="Suggestive Fix for an email typo", + default=None, + ) + domain_age_days: Optional[str] = SchemaField( + description="Age of the email domain in days or [null].", + default=None, + ) + free_email: Optional[bool] = SchemaField( + description="Whether the email address is a free email provider.", default=False + ) + mx_found: Optional[bool] = SchemaField( + description="Whether the MX record was found.", default=False + ) + mx_record: Optional[str] = SchemaField( + description="The MX record of the email address.", default=None + ) + smtp_provider: Optional[str] = SchemaField( + description="The SMTP provider of the email address.", default=None + ) + firstname: Optional[str] = SchemaField( + description="The first name of the email address.", default=None + ) + lastname: Optional[str] = SchemaField( + description="The last name of the email address.", default=None + ) + gender: Optional[str] = SchemaField( + description="The gender of the email address.", default=None + ) + city: Optional[str] = SchemaField( + description="The city of the email address.", default=None + ) + region: Optional[str] = SchemaField( + description="The region of the email address.", default=None + ) + zipcode: Optional[str] = SchemaField( + description="The zipcode of the email address.", default=None + ) + country: Optional[str] = SchemaField( + description="The country of the email address.", default=None + ) + + +class ValidateEmailsBlock(Block): + """Search for people in Apollo""" + + class Input(BlockSchema): + email: str = SchemaField( + description="Email to validate", + ) + ip_address: str = SchemaField( + description="IP address to validate", + default="", + ) + credentials: ZeroBounceCredentialsInput = CredentialsField( + description="ZeroBounce credentials", + ) + + class Output(BlockSchema): + response: Response = SchemaField( + description="Response from ZeroBounce", + ) + error: str = SchemaField( + description="Error message if the search failed", + default="", + ) + + def __init__(self): + super().__init__( + id="e3950439-fa0b-40e8-b19f-e0dca0bf5853", + description="Validate emails", + categories={BlockCategory.SEARCH}, + input_schema=ValidateEmailsBlock.Input, + output_schema=ValidateEmailsBlock.Output, + test_credentials=TEST_CREDENTIALS, + test_input={ + "credentials": TEST_CREDENTIALS_INPUT, + "email": "test@test.com", + }, + test_output=[ + ( + "response", + Response( + address="test@test.com", + status=ZBValidateStatus.valid, + sub_status=ZBValidateSubStatus.allowed, + account="test", + domain="test.com", + did_you_mean=None, + domain_age_days=None, + free_email=False, + mx_found=False, + mx_record=None, + smtp_provider=None, + ), + ) + ], + test_mock={ + "validate_email": lambda email, ip_address, credentials: ZBValidateResponse( + data={ + "address": email, + "status": ZBValidateStatus.valid, + "sub_status": ZBValidateSubStatus.allowed, + "account": "test", + "domain": "test.com", + "did_you_mean": None, + "domain_age_days": None, + "free_email": False, + "mx_found": False, + "mx_record": None, + "smtp_provider": None, + } + ) + }, + ) + + @staticmethod + def validate_email( + email: str, ip_address: str, credentials: ZeroBounceCredentials + ) -> ZBValidateResponse: + client = ZeroBounceClient(credentials.api_key.get_secret_value()) + return client.validate_email(email, ip_address) + + async def run( + self, + input_data: Input, + *, + credentials: ZeroBounceCredentials, + **kwargs, + ) -> BlockOutput: + response: ZBValidateResponse = self.validate_email( + input_data.email, input_data.ip_address, credentials + ) + + response_model = Response(**response.__dict__) + + yield "response", response_model diff --git a/autogpt_platform/backend/backend/check_db.py b/autogpt_platform/backend/backend/check_db.py new file mode 100644 index 000000000000..591c519f8461 --- /dev/null +++ b/autogpt_platform/backend/backend/check_db.py @@ -0,0 +1,359 @@ +import asyncio +import random +from datetime import datetime + +from faker import Faker +from prisma import Prisma + +faker = Faker() + + +async def check_cron_job(db): + """Check if the pg_cron job for refreshing materialized views exists.""" + print("\n1. Checking pg_cron job...") + print("-" * 40) + + try: + # Check if pg_cron extension exists + extension_check = await db.query_raw("CREATE EXTENSION pg_cron;") + print(extension_check) + extension_check = await db.query_raw( + "SELECT COUNT(*) as count FROM pg_extension WHERE extname = 'pg_cron'" + ) + if extension_check[0]["count"] == 0: + print("⚠️ pg_cron extension is not installed") + return False + + # Check if the refresh job exists + job_check = await db.query_raw( + """ + SELECT jobname, schedule, command + FROM cron.job + WHERE jobname = 'refresh-store-views' + """ + ) + + if job_check: + job = job_check[0] + print("✅ pg_cron job found:") + print(f" Name: {job['jobname']}") + print(f" Schedule: {job['schedule']} (every 15 minutes)") + print(f" Command: {job['command']}") + return True + else: + print("⚠️ pg_cron job 'refresh-store-views' not found") + return False + + except Exception as e: + print(f"❌ Error checking pg_cron: {e}") + return False + + +async def get_materialized_view_counts(db): + """Get current counts from materialized views.""" + print("\n2. Getting current materialized view data...") + print("-" * 40) + + # Get counts from mv_agent_run_counts + agent_runs = await db.query_raw( + """ + SELECT COUNT(*) as total_agents, + SUM(run_count) as total_runs, + MAX(run_count) as max_runs, + MIN(run_count) as min_runs + FROM mv_agent_run_counts + """ + ) + + # Get counts from mv_review_stats + review_stats = await db.query_raw( + """ + SELECT COUNT(*) as total_listings, + SUM(review_count) as total_reviews, + AVG(avg_rating) as overall_avg_rating + FROM mv_review_stats + """ + ) + + # Get sample data from StoreAgent view + store_agents = await db.query_raw( + """ + SELECT COUNT(*) as total_store_agents, + AVG(runs) as avg_runs, + AVG(rating) as avg_rating + FROM "StoreAgent" + """ + ) + + agent_run_data = agent_runs[0] if agent_runs else {} + review_data = review_stats[0] if review_stats else {} + store_data = store_agents[0] if store_agents else {} + + print("📊 mv_agent_run_counts:") + print(f" Total agents: {agent_run_data.get('total_agents', 0)}") + print(f" Total runs: {agent_run_data.get('total_runs', 0)}") + print(f" Max runs per agent: {agent_run_data.get('max_runs', 0)}") + print(f" Min runs per agent: {agent_run_data.get('min_runs', 0)}") + + print("\n📊 mv_review_stats:") + print(f" Total listings: {review_data.get('total_listings', 0)}") + print(f" Total reviews: {review_data.get('total_reviews', 0)}") + print(f" Overall avg rating: {review_data.get('overall_avg_rating') or 0:.2f}") + + print("\n📊 StoreAgent view:") + print(f" Total store agents: {store_data.get('total_store_agents', 0)}") + print(f" Average runs: {store_data.get('avg_runs') or 0:.2f}") + print(f" Average rating: {store_data.get('avg_rating') or 0:.2f}") + + return { + "agent_runs": agent_run_data, + "reviews": review_data, + "store_agents": store_data, + } + + +async def add_test_data(db): + """Add some test data to verify materialized view updates.""" + print("\n3. Adding test data...") + print("-" * 40) + + # Get some existing data + users = await db.user.find_many(take=5) + graphs = await db.agentgraph.find_many(take=5) + + if not users or not graphs: + print("❌ No existing users or graphs found. Run test_data_creator.py first.") + return False + + # Add new executions + print("Adding new agent graph executions...") + new_executions = 0 + for graph in graphs: + for _ in range(random.randint(2, 5)): + await db.agentgraphexecution.create( + data={ + "agentGraphId": graph.id, + "agentGraphVersion": graph.version, + "userId": random.choice(users).id, + "executionStatus": "COMPLETED", + "startedAt": datetime.now(), + } + ) + new_executions += 1 + + print(f"✅ Added {new_executions} new executions") + + # Check if we need to create store listings first + store_versions = await db.storelistingversion.find_many( + where={"submissionStatus": "APPROVED"}, take=5 + ) + + if not store_versions: + print("\nNo approved store listings found. Creating test store listings...") + + # Create store listings for existing agent graphs + for i, graph in enumerate(graphs[:3]): # Create up to 3 store listings + # Create a store listing + listing = await db.storelisting.create( + data={ + "slug": f"test-agent-{graph.id[:8]}", + "agentGraphId": graph.id, + "agentGraphVersion": graph.version, + "hasApprovedVersion": True, + "owningUserId": graph.userId, + } + ) + + # Create an approved version + version = await db.storelistingversion.create( + data={ + "storeListingId": listing.id, + "agentGraphId": graph.id, + "agentGraphVersion": graph.version, + "name": f"Test Agent {i+1}", + "subHeading": faker.catch_phrase(), + "description": faker.paragraph(nb_sentences=5), + "imageUrls": [faker.image_url()], + "categories": ["productivity", "automation"], + "submissionStatus": "APPROVED", + "submittedAt": datetime.now(), + } + ) + + # Update listing with active version + await db.storelisting.update( + where={"id": listing.id}, data={"activeVersionId": version.id} + ) + + print("✅ Created test store listings") + + # Re-fetch approved versions + store_versions = await db.storelistingversion.find_many( + where={"submissionStatus": "APPROVED"}, take=5 + ) + + # Add new reviews + print("\nAdding new store listing reviews...") + new_reviews = 0 + for version in store_versions: + # Find users who haven't reviewed this version + existing_reviews = await db.storelistingreview.find_many( + where={"storeListingVersionId": version.id} + ) + reviewed_user_ids = {r.reviewByUserId for r in existing_reviews} + available_users = [u for u in users if u.id not in reviewed_user_ids] + + if available_users: + user = random.choice(available_users) + await db.storelistingreview.create( + data={ + "storeListingVersionId": version.id, + "reviewByUserId": user.id, + "score": random.randint(3, 5), + "comments": faker.text(max_nb_chars=100), + } + ) + new_reviews += 1 + + print(f"✅ Added {new_reviews} new reviews") + + return True + + +async def refresh_materialized_views(db): + """Manually refresh the materialized views.""" + print("\n4. Manually refreshing materialized views...") + print("-" * 40) + + try: + await db.execute_raw("SELECT refresh_store_materialized_views();") + print("✅ Materialized views refreshed successfully") + return True + except Exception as e: + print(f"❌ Error refreshing views: {e}") + return False + + +async def compare_counts(before, after): + """Compare counts before and after refresh.""" + print("\n5. Comparing counts before and after refresh...") + print("-" * 40) + + # Compare agent runs + print("🔍 Agent run changes:") + before_runs = before["agent_runs"].get("total_runs") or 0 + after_runs = after["agent_runs"].get("total_runs") or 0 + print( + f" Total runs: {before_runs} → {after_runs} " f"(+{after_runs - before_runs})" + ) + + # Compare reviews + print("\n🔍 Review changes:") + before_reviews = before["reviews"].get("total_reviews") or 0 + after_reviews = after["reviews"].get("total_reviews") or 0 + print( + f" Total reviews: {before_reviews} → {after_reviews} " + f"(+{after_reviews - before_reviews})" + ) + + # Compare store agents + print("\n🔍 StoreAgent view changes:") + before_avg_runs = before["store_agents"].get("avg_runs", 0) or 0 + after_avg_runs = after["store_agents"].get("avg_runs", 0) or 0 + print( + f" Average runs: {before_avg_runs:.2f} → {after_avg_runs:.2f} " + f"(+{after_avg_runs - before_avg_runs:.2f})" + ) + + # Verify changes occurred + runs_changed = (after["agent_runs"].get("total_runs") or 0) > ( + before["agent_runs"].get("total_runs") or 0 + ) + reviews_changed = (after["reviews"].get("total_reviews") or 0) > ( + before["reviews"].get("total_reviews") or 0 + ) + + if runs_changed and reviews_changed: + print("\n✅ Materialized views are updating correctly!") + return True + else: + print("\n⚠️ Some materialized views may not have updated:") + if not runs_changed: + print(" - Agent run counts did not increase") + if not reviews_changed: + print(" - Review counts did not increase") + return False + + +async def main(): + db = Prisma() + await db.connect() + + print("=" * 60) + print("Materialized Views Test") + print("=" * 60) + + try: + # Check if data exists + user_count = await db.user.count() + if user_count == 0: + print("❌ No data in database. Please run test_data_creator.py first.") + await db.disconnect() + return + + # 1. Check cron job + cron_exists = await check_cron_job(db) + + # 2. Get initial counts + counts_before = await get_materialized_view_counts(db) + + # 3. Add test data + data_added = await add_test_data(db) + refresh_success = False + + if data_added: + # Wait a moment for data to be committed + print("\nWaiting for data to be committed...") + await asyncio.sleep(2) + + # 4. Manually refresh views + refresh_success = await refresh_materialized_views(db) + + if refresh_success: + # 5. Get counts after refresh + counts_after = await get_materialized_view_counts(db) + + # 6. Compare results + await compare_counts(counts_before, counts_after) + + # Summary + print("\n" + "=" * 60) + print("Test Summary") + print("=" * 60) + print(f"✓ pg_cron job exists: {'Yes' if cron_exists else 'No'}") + print(f"✓ Test data added: {'Yes' if data_added else 'No'}") + print(f"✓ Manual refresh worked: {'Yes' if refresh_success else 'No'}") + print( + f"✓ Views updated correctly: {'Yes' if data_added and refresh_success else 'Cannot verify'}" + ) + + if cron_exists: + print( + "\n💡 The materialized views will also refresh automatically every 15 minutes via pg_cron." + ) + else: + print( + "\n⚠️ Automatic refresh is not configured. Views must be refreshed manually." + ) + + except Exception as e: + print(f"\n❌ Test failed with error: {e}") + import traceback + + traceback.print_exc() + + await db.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/autogpt_platform/backend/backend/check_store_data.py b/autogpt_platform/backend/backend/check_store_data.py new file mode 100644 index 000000000000..10aa6507baf8 --- /dev/null +++ b/autogpt_platform/backend/backend/check_store_data.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Check store-related data in the database.""" + +import asyncio + +from prisma import Prisma + + +async def check_store_data(db): + """Check what store data exists in the database.""" + + print("============================================================") + print("Store Data Check") + print("============================================================") + + # Check store listings + print("\n1. Store Listings:") + print("-" * 40) + listings = await db.storelisting.find_many() + print(f"Total store listings: {len(listings)}") + + if listings: + for listing in listings[:5]: + print(f"\nListing ID: {listing.id}") + print(f" Name: {listing.name}") + print(f" Status: {listing.status}") + print(f" Slug: {listing.slug}") + + # Check store listing versions + print("\n\n2. Store Listing Versions:") + print("-" * 40) + versions = await db.storelistingversion.find_many(include={"StoreListing": True}) + print(f"Total store listing versions: {len(versions)}") + + # Group by submission status + status_counts = {} + for version in versions: + status = version.submissionStatus + status_counts[status] = status_counts.get(status, 0) + 1 + + print("\nVersions by status:") + for status, count in status_counts.items(): + print(f" {status}: {count}") + + # Show approved versions + approved_versions = [v for v in versions if v.submissionStatus == "APPROVED"] + print(f"\nApproved versions: {len(approved_versions)}") + if approved_versions: + for version in approved_versions[:5]: + print(f"\n Version ID: {version.id}") + print(f" Listing: {version.StoreListing.name}") + print(f" Version: {version.version}") + + # Check store listing reviews + print("\n\n3. Store Listing Reviews:") + print("-" * 40) + reviews = await db.storelistingreview.find_many( + include={"StoreListingVersion": {"include": {"StoreListing": True}}} + ) + print(f"Total reviews: {len(reviews)}") + + if reviews: + # Calculate average rating + total_score = sum(r.score for r in reviews) + avg_score = total_score / len(reviews) if reviews else 0 + print(f"Average rating: {avg_score:.2f}") + + # Show sample reviews + print("\nSample reviews:") + for review in reviews[:3]: + print(f"\n Review for: {review.StoreListingVersion.StoreListing.name}") + print(f" Score: {review.score}") + print(f" Comments: {review.comments[:100]}...") + + # Check StoreAgent view data + print("\n\n4. StoreAgent View Data:") + print("-" * 40) + + # Query the StoreAgent view + query = """ + SELECT + sa.listing_id, + sa.slug, + sa.agent_name, + sa.description, + sa.featured, + sa.runs, + sa.rating, + sa.creator_username, + sa.categories, + sa.updated_at + FROM "StoreAgent" sa + LIMIT 10; + """ + + store_agents = await db.query_raw(query) + print(f"Total store agents in view: {len(store_agents)}") + + if store_agents: + for agent in store_agents[:5]: + print(f"\nStore Agent: {agent['agent_name']}") + print(f" Slug: {agent['slug']}") + print(f" Runs: {agent['runs']}") + print(f" Rating: {agent['rating']}") + print(f" Creator: {agent['creator_username']}") + + # Check the underlying data that should populate StoreAgent + print("\n\n5. Data that should populate StoreAgent view:") + print("-" * 40) + + # Check for any APPROVED store listing versions + query = """ + SELECT COUNT(*) as count + FROM "StoreListingVersion" + WHERE "submissionStatus" = 'APPROVED' + """ + + result = await db.query_raw(query) + approved_count = result[0]["count"] if result else 0 + print(f"Approved store listing versions: {approved_count}") + + # Check for store listings with hasApprovedVersion = true + query = """ + SELECT COUNT(*) as count + FROM "StoreListing" + WHERE "hasApprovedVersion" = true AND "isDeleted" = false + """ + + result = await db.query_raw(query) + has_approved_count = result[0]["count"] if result else 0 + print(f"Store listings with approved versions: {has_approved_count}") + + # Check agent graph executions + query = """ + SELECT COUNT(DISTINCT "agentGraphId") as unique_agents, + COUNT(*) as total_executions + FROM "AgentGraphExecution" + """ + + result = await db.query_raw(query) + if result: + print("\nAgent Graph Executions:") + print(f" Unique agents with executions: {result[0]['unique_agents']}") + print(f" Total executions: {result[0]['total_executions']}") + + +async def main(): + """Main function.""" + db = Prisma() + await db.connect() + + try: + await check_store_data(db) + finally: + await db.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/autogpt_platform/backend/backend/cli.py b/autogpt_platform/backend/backend/cli.py index efaadd02b282..988961b2de40 100755 --- a/autogpt_platform/backend/backend/cli.py +++ b/autogpt_platform/backend/backend/cli.py @@ -8,7 +8,6 @@ import click import psutil -from backend import app from backend.util.process import AppProcess @@ -42,8 +41,13 @@ def write_pid(pid: int): class MainApp(AppProcess): def run(self): + from backend import app + app.main(silent=True) + def cleanup(self): + pass + @click.group() def main(): @@ -113,20 +117,21 @@ def test(): @test.command() @click.argument("server_address") -def reddit(server_address: str): +async def reddit(server_address: str): """ Create an event graph """ - import requests - from backend.usecases.reddit_marketing import create_test_graph + from backend.util.request import Requests test_graph = create_test_graph() url = f"{server_address}/graphs" headers = {"Content-Type": "application/json"} data = test_graph.model_dump_json() - response = requests.post(url, headers=headers, data=data) + response = await Requests(trusted_origins=[server_address]).post( + url, headers=headers, data=data + ) graph_id = response.json()["id"] print(f"Graph created with ID: {graph_id}") @@ -134,28 +139,32 @@ def reddit(server_address: str): @test.command() @click.argument("server_address") -def populate_db(server_address: str): +async def populate_db(server_address: str): """ Create an event graph """ - import requests from backend.usecases.sample import create_test_graph + from backend.util.request import Requests test_graph = create_test_graph() url = f"{server_address}/graphs" headers = {"Content-Type": "application/json"} data = test_graph.model_dump_json() - response = requests.post(url, headers=headers, data=data) + response = await Requests(trusted_origins=[server_address]).post( + url, headers=headers, data=data + ) graph_id = response.json()["id"] - if response.status_code == 200: + if response.status == 200: execute_url = f"{server_address}/graphs/{response.json()['id']}/execute" text = "Hello, World!" input_data = {"input": text} - response = requests.post(execute_url, headers=headers, json=input_data) + response = Requests(trusted_origins=[server_address]).post( + execute_url, headers=headers, json=input_data + ) schedule_url = f"{server_address}/graphs/{graph_id}/schedules" data = { @@ -163,51 +172,60 @@ def populate_db(server_address: str): "cron": "*/5 * * * *", "input_data": {"input": "Hello, World!"}, } - response = requests.post(schedule_url, headers=headers, json=data) + response = Requests(trusted_origins=[server_address]).post( + schedule_url, headers=headers, json=data + ) print("Database populated with: \n- graph\n- execution\n- schedule") @test.command() @click.argument("server_address") -def graph(server_address: str): +async def graph(server_address: str): """ Create an event graph """ - import requests from backend.usecases.sample import create_test_graph + from backend.util.request import Requests url = f"{server_address}/graphs" headers = {"Content-Type": "application/json"} data = create_test_graph().model_dump_json() - response = requests.post(url, headers=headers, data=data) + response = await Requests(trusted_origins=[server_address]).post( + url, headers=headers, data=data + ) - if response.status_code == 200: + if response.status == 200: print(response.json()["id"]) execute_url = f"{server_address}/graphs/{response.json()['id']}/execute" text = "Hello, World!" input_data = {"input": text} - response = requests.post(execute_url, headers=headers, json=input_data) + response = await Requests(trusted_origins=[server_address]).post( + execute_url, headers=headers, json=input_data + ) else: print("Failed to send graph") - print(f"Response: {response.text}") + print(f"Response: {response.text()}") @test.command() @click.argument("graph_id") @click.argument("content") -def execute(graph_id: str, content: dict): +async def execute(graph_id: str, content: dict): """ Create an event graph """ - import requests + + from backend.util.request import Requests headers = {"Content-Type": "application/json"} execute_url = f"http://0.0.0.0:8000/graphs/{graph_id}/execute" - requests.post(execute_url, headers=headers, json=content) + await Requests(trusted_origins=["http://0.0.0.0:8000"]).post( + execute_url, headers=headers, json=content + ) @test.command() @@ -220,8 +238,8 @@ def event(): @test.command() @click.argument("server_address") -@click.argument("graph_id") -def websocket(server_address: str, graph_id: str): +@click.argument("graph_exec_id") +def websocket(server_address: str, graph_exec_id: str): """ Tests the websocket connection. """ @@ -229,15 +247,21 @@ def websocket(server_address: str, graph_id: str): import websockets.asyncio.client - from backend.server.ws_api import ExecutionSubscription, Methods, WsMessage + from backend.server.ws_api import ( + WSMessage, + WSMethod, + WSSubscribeGraphExecutionRequest, + ) async def send_message(server_address: str): uri = f"ws://{server_address}" async with websockets.asyncio.client.connect(uri) as websocket: try: - msg = WsMessage( - method=Methods.SUBSCRIBE, - data=ExecutionSubscription(graph_id=graph_id).model_dump(), + msg = WSMessage( + method=WSMethod.SUBSCRIBE_GRAPH_EXEC, + data=WSSubscribeGraphExecutionRequest( + graph_exec_id=graph_exec_id, + ).model_dump(), ).model_dump_json() await websocket.send(msg) print(f"Sending: {msg}") diff --git a/autogpt_platform/backend/backend/conftest.py b/autogpt_platform/backend/backend/conftest.py new file mode 100644 index 000000000000..d73ed7712c23 --- /dev/null +++ b/autogpt_platform/backend/backend/conftest.py @@ -0,0 +1,55 @@ +import logging +import os + +import pytest +from dotenv import load_dotenv + +from backend.util.logging import configure_logging + +os.environ["ENABLE_AUTH"] = "false" + +load_dotenv() + +# NOTE: You can run tests like with the --log-cli-level=INFO to see the logs +# Set up logging +configure_logging() +logger = logging.getLogger(__name__) + +# Reduce Prisma log spam unless PRISMA_DEBUG is set +if not os.getenv("PRISMA_DEBUG"): + prisma_logger = logging.getLogger("prisma") + prisma_logger.setLevel(logging.INFO) + + +@pytest.fixture(scope="session") +async def server(): + from backend.util.test import SpinTestServer + + async with SpinTestServer() as server: + yield server + + +@pytest.fixture(scope="session", autouse=True) +async def graph_cleanup(server): + created_graph_ids = [] + original_create_graph = server.agent_server.test_create_graph + + async def create_graph_wrapper(*args, **kwargs): + created_graph = await original_create_graph(*args, **kwargs) + # Extract user_id correctly + user_id = kwargs.get("user_id", args[2] if len(args) > 2 else None) + created_graph_ids.append((created_graph.id, user_id)) + return created_graph + + try: + server.agent_server.test_create_graph = create_graph_wrapper + yield # This runs the test function + finally: + server.agent_server.test_create_graph = original_create_graph + + # Delete the created graphs and assert they were deleted + for graph_id, user_id in created_graph_ids: + if user_id: + resp = await server.agent_server.test_delete_graph(graph_id, user_id) + num_deleted = resp["version_counts"] + assert num_deleted > 0, f"Graph {graph_id} was not deleted." diff --git a/autogpt_platform/backend/backend/data/__init__.py b/autogpt_platform/backend/backend/data/__init__.py new file mode 100644 index 000000000000..7cbc4487be58 --- /dev/null +++ b/autogpt_platform/backend/backend/data/__init__.py @@ -0,0 +1,5 @@ +from .graph import NodeModel +from .integrations import Webhook # noqa: F401 + +# Resolve Webhook <- NodeModel forward reference +NodeModel.model_rebuild() diff --git a/autogpt_platform/backend/backend/data/analytics.py b/autogpt_platform/backend/backend/data/analytics.py index e0ee6bc4e2b5..fde2d3fd6e50 100644 --- a/autogpt_platform/backend/backend/data/analytics.py +++ b/autogpt_platform/backend/backend/data/analytics.py @@ -2,6 +2,8 @@ import prisma.types +from backend.util.json import SafeJson + logger = logging.getLogger(__name__) @@ -12,12 +14,12 @@ async def log_raw_analytics( data_index: str, ): details = await prisma.models.AnalyticsDetails.prisma().create( - data={ - "userId": user_id, - "type": type, - "data": prisma.Json(data), - "dataIndex": data_index, - } + data=prisma.types.AnalyticsDetailsCreateInput( + userId=user_id, + type=type, + data=SafeJson(data), + dataIndex=data_index, + ) ) return details @@ -32,12 +34,12 @@ async def log_raw_metric( raise ValueError("metric_value must be non-negative") result = await prisma.models.AnalyticsMetrics.prisma().create( - data={ - "value": metric_value, - "analyticMetric": metric_name, - "userId": user_id, - "dataString": data_string, - }, + data=prisma.types.AnalyticsMetricsCreateInput( + value=metric_value, + analyticMetric=metric_name, + userId=user_id, + dataString=data_string, + ) ) return result diff --git a/autogpt_platform/backend/backend/data/block.py b/autogpt_platform/backend/backend/data/block.py index ea293dc197c4..0b47ae9362d7 100644 --- a/autogpt_platform/backend/backend/data/block.py +++ b/autogpt_platform/backend/backend/data/block.py @@ -1,12 +1,17 @@ +import functools import inspect +import logging +import os from abc import ABC, abstractmethod +from collections.abc import AsyncGenerator as AsyncGen from enum import Enum from typing import ( + TYPE_CHECKING, Any, ClassVar, - Generator, Generic, Optional, + Sequence, Type, TypeVar, cast, @@ -16,23 +21,32 @@ import jsonref import jsonschema from prisma.models import AgentBlock +from prisma.types import AgentBlockCreateInput from pydantic import BaseModel +from backend.data.model import NodeExecutionStats +from backend.integrations.providers import ProviderName from backend.util import json from backend.util.settings import Config from .model import ( - CREDENTIALS_FIELD_NAME, ContributorDetails, Credentials, + CredentialsFieldInfo, CredentialsMetaInput, + is_credentials_field_name, ) +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from .graph import Link + app_config = Config() BlockData = tuple[str, Any] # Input & Output data should be a tuple of (name, data). BlockInput = dict[str, Any] # Input: 1 input pin consumes 1 data. -BlockOutput = Generator[BlockData, None, None] # Output: 1 output pin produces n data. +BlockOutput = AsyncGen[BlockData, None] # Output: 1 output pin produces n data. CompletedBlockOutput = dict[str, list[Any]] # Completed stream, collected as a dict. @@ -44,6 +58,8 @@ class BlockType(Enum): WEBHOOK = "Webhook" WEBHOOK_MANUAL = "Webhook (manual)" AGENT = "Agent" + AI = "AI" + AYRSHARE = "Ayrshare" class BlockCategory(Enum): @@ -61,6 +77,13 @@ class BlockCategory(Enum): HARDWARE = "Block that interacts with hardware." AGENT = "Block that interacts with other agents." CRM = "Block that interacts with CRM services." + SAFETY = ( + "Block that provides AI safety mechanisms such as detecting harmful content" + ) + PRODUCTIVITY = "Block that helps with productivity" + ISSUE_TRACKING = "Block that helps with issue tracking" + MULTIMEDIA = "Block that interacts with multimedia content" + MARKETING = "Block that helps with marketing" def dict(self) -> dict[str, str]: return {"category": self.name, "description": self.value} @@ -97,32 +120,39 @@ def ref_to_dict(obj): cls.cached_jsonschema = cast(dict[str, Any], ref_to_dict(model)) - # Set default properties values - for field in cls.cached_jsonschema.get("properties", {}).values(): - if isinstance(field, dict) and "advanced" not in field: - field["advanced"] = True - return cls.cached_jsonschema @classmethod def validate_data(cls, data: BlockInput) -> str | None: - return json.validate_with_jsonschema(schema=cls.jsonschema(), data=data) + return json.validate_with_jsonschema( + schema=cls.jsonschema(), + data={k: v for k, v in data.items() if v is not None}, + ) @classmethod - def validate_field(cls, field_name: str, data: BlockInput) -> str | None: - """ - Validate the data against a specific property (one of the input/output name). - Returns the validation error message if the data does not match the schema. - """ + def get_mismatch_error(cls, data: BlockInput) -> str | None: + return cls.validate_data(data) + + @classmethod + def get_field_schema(cls, field_name: str) -> dict[str, Any]: model_schema = cls.jsonschema().get("properties", {}) if not model_schema: - return f"Invalid model schema {cls}" + raise ValueError(f"Invalid model schema {cls}") property_schema = model_schema.get(field_name) if not property_schema: - return f"Invalid property name {field_name}" + raise ValueError(f"Invalid property name {field_name}") + + return property_schema + @classmethod + def validate_field(cls, field_name: str, data: BlockInput) -> str | None: + """ + Validate the data against a specific property (one of the input/output name). + Returns the validation error message if the data does not match the schema. + """ try: + property_schema = cls.get_field_schema(field_name) jsonschema.validate(json.to_dict(data), property_schema) return None except jsonschema.ValidationError as e: @@ -143,17 +173,38 @@ def get_required_fields(cls) -> set[str]: @classmethod def __pydantic_init_subclass__(cls, **kwargs): """Validates the schema definition. Rules: - - Only one `CredentialsMetaInput` field may be present. - - This field MUST be called `credentials`. - - A field that is called `credentials` MUST be a `CredentialsMetaInput`. + - Fields with annotation `CredentialsMetaInput` MUST be + named `credentials` or `*_credentials` + - Fields named `credentials` or `*_credentials` MUST be + of type `CredentialsMetaInput` """ super().__pydantic_init_subclass__(**kwargs) # Reset cached JSON schema to prevent inheriting it from parent class cls.cached_jsonschema = {} - credentials_fields = [ - field_name + credentials_fields = cls.get_credentials_fields() + + for field_name in cls.get_fields(): + if is_credentials_field_name(field_name): + if field_name not in credentials_fields: + raise TypeError( + f"Credentials field '{field_name}' on {cls.__qualname__} " + f"is not of type {CredentialsMetaInput.__name__}" + ) + + credentials_fields[field_name].validate_credentials_field_schema(cls) + + elif field_name in credentials_fields: + raise KeyError( + f"Credentials field '{field_name}' on {cls.__qualname__} " + "has invalid name: must be 'credentials' or *_credentials" + ) + + @classmethod + def get_credentials_fields(cls) -> dict[str, type[CredentialsMetaInput]]: + return { + field_name: info.annotation for field_name, info in cls.model_fields.items() if ( inspect.isclass(info.annotation) @@ -162,32 +213,29 @@ def __pydantic_init_subclass__(cls, **kwargs): CredentialsMetaInput, ) ) - ] - if len(credentials_fields) > 1: - raise ValueError( - f"{cls.__qualname__} can only have one CredentialsMetaInput field" - ) - elif ( - len(credentials_fields) == 1 - and credentials_fields[0] != CREDENTIALS_FIELD_NAME - ): - raise ValueError( - f"CredentialsMetaInput field on {cls.__qualname__} " - "must be named 'credentials'" - ) - elif ( - len(credentials_fields) == 0 - and CREDENTIALS_FIELD_NAME in cls.model_fields.keys() - ): - raise TypeError( - f"Field 'credentials' on {cls.__qualname__} " - f"must be of type {CredentialsMetaInput.__name__}" - ) - if credentials_field := cls.model_fields.get(CREDENTIALS_FIELD_NAME): - credentials_input_type = cast( - CredentialsMetaInput, credentials_field.annotation + } + + @classmethod + def get_credentials_fields_info(cls) -> dict[str, CredentialsFieldInfo]: + return { + field_name: CredentialsFieldInfo.model_validate( + cls.get_field_schema(field_name), by_alias=True ) - credentials_input_type.validate_credentials_field_schema(cls) + for field_name in cls.get_credentials_fields().keys() + } + + @classmethod + def get_input_defaults(cls, data: BlockInput) -> BlockInput: + return data # Return as is, by default. + + @classmethod + def get_missing_links(cls, data: BlockInput, links: list["Link"]) -> set[str]: + input_fields_from_nodes = {link.sink_name for link in links} + return input_fields_from_nodes - set(data) + + @classmethod + def get_missing_input(cls, data: BlockInput) -> set[str]: + return cls.get_required_fields() - set(data) BlockSchemaInputType = TypeVar("BlockSchemaInputType", bound=BlockSchema) @@ -205,7 +253,7 @@ class BlockManualWebhookConfig(BaseModel): the user has to manually set up the webhook at the provider. """ - provider: str + provider: ProviderName """The service provider that the webhook connects to""" webhook_type: str @@ -260,7 +308,7 @@ def __init__( test_input: BlockInput | list[BlockInput] | None = None, test_output: BlockData | list[BlockData] | None = None, test_mock: dict[str, Any] | None = None, - test_credentials: Optional[Credentials] = None, + test_credentials: Optional[Credentials | dict[str, Credentials]] = None, disabled: bool = False, static_output: bool = False, block_type: BlockType = BlockType.STANDARD, @@ -297,15 +345,21 @@ def __init__( self.static_output = static_output self.block_type = block_type self.webhook_config = webhook_config - self.execution_stats = {} + self.execution_stats: NodeExecutionStats = NodeExecutionStats() if self.webhook_config: if isinstance(self.webhook_config, BlockWebhookConfig): # Enforce presence of credentials field on auto-setup webhook blocks - if CREDENTIALS_FIELD_NAME not in self.input_schema.model_fields: + if not (cred_fields := self.input_schema.get_credentials_fields()): raise TypeError( "credentials field is required on auto-setup webhook blocks" ) + # Disallow multiple credentials inputs on webhook blocks + elif len(cred_fields) > 1: + raise ValueError( + "Multiple credentials inputs not supported on webhook blocks" + ) + self.block_type = BlockType.WEBHOOK else: self.block_type = BlockType.WEBHOOK_MANUAL @@ -343,36 +397,40 @@ def create(cls: Type["Block"]) -> "Block": return cls() @abstractmethod - def run(self, input_data: BlockSchemaInputType, **kwargs) -> BlockOutput: + async def run(self, input_data: BlockSchemaInputType, **kwargs) -> BlockOutput: """ Run the block with the given input data. Args: input_data: The input data with the structure of input_schema. + + Kwargs: Currently 14/02/2025 these include + graph_id: The ID of the graph. + node_id: The ID of the node. + graph_exec_id: The ID of the graph execution. + node_exec_id: The ID of the node execution. + user_id: The ID of the user. + Returns: A Generator that yields (output_name, output_data). output_name: One of the output name defined in Block's output_schema. output_data: The data for the output_name, matching the defined schema. """ - pass - - def run_once(self, input_data: BlockSchemaInputType, output: str, **kwargs) -> Any: - for name, data in self.run(input_data, **kwargs): + # --- satisfy the type checker, never executed ------------- + if False: # noqa: SIM115 + yield "name", "value" # pyright: ignore[reportMissingYield] + raise NotImplementedError(f"{self.name} does not implement the run method.") + + async def run_once( + self, input_data: BlockSchemaInputType, output: str, **kwargs + ) -> Any: + async for item in self.run(input_data, **kwargs): + name, data = item if name == output: return data raise ValueError(f"{self.name} did not produce any output for {output}") - def merge_stats(self, stats: dict[str, Any]) -> dict[str, Any]: - for key, value in stats.items(): - if isinstance(value, dict): - self.execution_stats.setdefault(key, {}).update(value) - elif isinstance(value, (int, float)): - self.execution_stats.setdefault(key, 0) - self.execution_stats[key] += value - elif isinstance(value, list): - self.execution_stats.setdefault(key, []) - self.execution_stats[key].extend(value) - else: - self.execution_stats[key] = value + def merge_stats(self, stats: NodeExecutionStats) -> NodeExecutionStats: + self.execution_stats += stats return self.execution_stats @property @@ -394,14 +452,15 @@ def to_dict(self): "uiType": self.block_type.value, } - def execute(self, input_data: BlockInput, **kwargs) -> BlockOutput: + async def execute(self, input_data: BlockInput, **kwargs) -> BlockOutput: if error := self.input_schema.validate_data(input_data): raise ValueError( f"Unable to execute block with invalid input data: {error}" ) - for output_name, output_data in self.run( - self.input_schema(**input_data), **kwargs + async for output_name, output_data in self.run( + self.input_schema(**{k: v for k, v in input_data.items() if v is not None}), + **kwargs, ): if output_name == "error": raise RuntimeError(output_data) @@ -411,17 +470,158 @@ def execute(self, input_data: BlockInput, **kwargs) -> BlockOutput: raise ValueError(f"Block produced an invalid output data: {error}") yield output_name, output_data + def is_triggered_by_event_type( + self, trigger_config: dict[str, Any], event_type: str + ) -> bool: + if not self.webhook_config: + raise TypeError("This method can't be used on non-trigger blocks") + if not self.webhook_config.event_filter_input: + return True + event_filter = trigger_config.get(self.webhook_config.event_filter_input) + if not event_filter: + raise ValueError("Event filter is not configured on trigger") + return event_type in [ + self.webhook_config.event_format.format(event=k) + for k in event_filter + if event_filter[k] is True + ] + # ======================= Block Helper Functions ======================= # def get_blocks() -> dict[str, Type[Block]]: - from backend.blocks import AVAILABLE_BLOCKS # noqa: E402 + from backend.blocks import load_all_blocks + + return load_all_blocks() + + +def is_block_auth_configured( + block_cls: type["Block[BlockSchema, BlockSchema]"], +) -> bool: + """ + Check if a block has a valid authentication method configured at runtime. + + For example if a block is an OAuth-only block and there env vars are not set, + do not show it in the UI. + + """ + from backend.sdk.registry import AutoRegistry + + # Create an instance to access input_schema + try: + block = block_cls() + except Exception as e: + # If we can't create a block instance, assume it's not OAuth-only + logger.error(f"Error creating block instance for {block_cls.__name__}: {e}") + return True + logger.debug( + f"Checking if block {block_cls.__name__} has a valid provider configured" + ) + + # Get all credential inputs from input schema + credential_inputs = block.input_schema.get_credentials_fields_info() + required_inputs = block.input_schema.get_required_fields() + if not credential_inputs: + logger.debug( + f"Block {block_cls.__name__} has no credential inputs - Treating as valid" + ) + return True + + # Check credential inputs + if len(required_inputs.intersection(credential_inputs.keys())) == 0: + logger.debug( + f"Block {block_cls.__name__} has only optional credential inputs" + " - will work without credentials configured" + ) + if len(credential_inputs) > 1: + logger.warning( + f"Block {block_cls.__name__} has multiple credential inputs: " + f"{', '.join(credential_inputs.keys())}" + ) + + # Check if the credential inputs for this block are correctly configured + for field_name, field_info in credential_inputs.items(): + provider_names = field_info.provider + if not provider_names: + logger.warning( + f"Block {block_cls.__name__} " + f"has credential input '{field_name}' with no provider options" + " - Disabling" + ) + return False + + # If a field has multiple possible providers, each one needs to be usable to + # prevent breaking the UX + for _provider_name in provider_names: + provider_name = _provider_name.value + if provider_name in ProviderName.__members__.values(): + logger.debug( + f"Block {block_cls.__name__} credential input '{field_name}' " + f"provider '{provider_name}' is part of the legacy provider system" + " - Treating as valid" + ) + break + + provider = AutoRegistry.get_provider(provider_name) + if not provider: + logger.warning( + f"Block {block_cls.__name__} credential input '{field_name}' " + f"refers to unknown provider '{provider_name}' - Disabling" + ) + return False + + # Check the provider's supported auth types + if field_info.supported_types != provider.supported_auth_types: + logger.warning( + f"Block {block_cls.__name__} credential input '{field_name}' " + f"has mismatched supported auth types (field <> Provider): " + f"{field_info.supported_types} != {provider.supported_auth_types}" + ) + + if not (supported_auth_types := provider.supported_auth_types): + # No auth methods are been configured for this provider + logger.warning( + f"Block {block_cls.__name__} credential input '{field_name}' " + f"provider '{provider_name}' " + "has no authentication methods configured - Disabling" + ) + return False + + # Check if provider supports OAuth + if "oauth2" in supported_auth_types: + # Check if OAuth environment variables are set + if (oauth_config := provider.oauth_config) and bool( + os.getenv(oauth_config.client_id_env_var) + and os.getenv(oauth_config.client_secret_env_var) + ): + logger.debug( + f"Block {block_cls.__name__} credential input '{field_name}' " + f"provider '{provider_name}' is configured for OAuth" + ) + else: + logger.error( + f"Block {block_cls.__name__} credential input '{field_name}' " + f"provider '{provider_name}' " + "is missing OAuth client ID or secret - Disabling" + ) + return False + + logger.debug( + f"Block {block_cls.__name__} credential input '{field_name}' is valid; " + f"supported credential types: {', '.join(field_info.supported_types)}" + ) - return AVAILABLE_BLOCKS + return True async def initialize_blocks() -> None: + # First, sync all provider costs to blocks + # Imported here to avoid circular import + from backend.sdk.cost_integration import sync_all_provider_costs + + sync_all_provider_costs() + for cls in get_blocks().values(): block = cls() existing_block = await AgentBlock.prisma().find_first( @@ -429,12 +629,12 @@ async def initialize_blocks() -> None: ) if not existing_block: await AgentBlock.prisma().create( - data={ - "id": block.id, - "name": block.name, - "inputSchema": json.dumps(block.input_schema.jsonschema()), - "outputSchema": json.dumps(block.output_schema.jsonschema()), - } + data=AgentBlockCreateInput( + id=block.id, + name=block.name, + inputSchema=json.dumps(block.input_schema.jsonschema()), + outputSchema=json.dumps(block.output_schema.jsonschema()), + ) ) continue @@ -457,6 +657,25 @@ async def initialize_blocks() -> None: ) -def get_block(block_id: str) -> Block | None: +# Note on the return type annotation: https://github.com/microsoft/pyright/issues/10281 +def get_block(block_id: str) -> Block[BlockSchema, BlockSchema] | None: cls = get_blocks().get(block_id) return cls() if cls else None + + +@functools.cache +def get_webhook_block_ids() -> Sequence[str]: + return [ + id + for id, B in get_blocks().items() + if B().block_type in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL) + ] + + +@functools.cache +def get_io_block_ids() -> Sequence[str]: + return [ + id + for id, B in get_blocks().items() + if B().block_type in (BlockType.INPUT, BlockType.OUTPUT) + ] diff --git a/autogpt_platform/backend/backend/data/block_cost_config.py b/autogpt_platform/backend/backend/data/block_cost_config.py index 4ed57b2b390d..64ad5222b32f 100644 --- a/autogpt_platform/backend/backend/data/block_cost_config.py +++ b/autogpt_platform/backend/backend/data/block_cost_config.py @@ -2,6 +2,16 @@ from backend.blocks.ai_music_generator import AIMusicGeneratorBlock from backend.blocks.ai_shortform_video_block import AIShortformVideoCreatorBlock +from backend.blocks.apollo.organization import SearchOrganizationsBlock +from backend.blocks.apollo.people import SearchPeopleBlock +from backend.blocks.apollo.person import GetPersonDetailBlock +from backend.blocks.enrichlayer.linkedin import ( + GetLinkedinProfileBlock, + GetLinkedinProfilePictureBlock, + LinkedinPersonLookupBlock, + LinkedinRoleLookupBlock, +) +from backend.blocks.flux_kontext import AIImageEditorBlock, FluxKontextModelName from backend.blocks.ideogram import IdeogramModelBlock from backend.blocks.jina.embeddings import JinaEmbeddingBlock from backend.blocks.jina.search import ExtractWebsiteContentBlock, SearchTheWebBlock @@ -14,55 +24,83 @@ AITextSummarizerBlock, LlmModel, ) -from backend.blocks.replicate_flux_advanced import ReplicateFluxAdvancedModelBlock +from backend.blocks.replicate.flux_advanced import ReplicateFluxAdvancedModelBlock +from backend.blocks.replicate.replicate_block import ReplicateModelBlock +from backend.blocks.smart_decision_maker import SmartDecisionMakerBlock from backend.blocks.talking_head import CreateTalkingAvatarVideoBlock from backend.blocks.text_to_speech_block import UnrealTextToSpeechBlock from backend.data.block import Block from backend.data.cost import BlockCost, BlockCostType from backend.integrations.credentials_store import ( + aiml_api_credentials, anthropic_credentials, + apollo_credentials, did_credentials, + enrichlayer_credentials, groq_credentials, ideogram_credentials, jina_credentials, + llama_api_credentials, open_router_credentials, openai_credentials, replicate_credentials, revid_credentials, unreal_credentials, + v0_credentials, ) # =============== Configure the cost for each LLM Model call =============== # MODEL_COST: dict[LlmModel, int] = { - LlmModel.O1_PREVIEW: 16, + LlmModel.O3: 4, + LlmModel.O3_MINI: 2, # $1.10 / $4.40 + LlmModel.O1: 16, # $15 / $60 LlmModel.O1_MINI: 4, + # GPT-5 models + LlmModel.GPT5: 2, + LlmModel.GPT5_MINI: 1, + LlmModel.GPT5_NANO: 1, + LlmModel.GPT5_CHAT: 2, + LlmModel.GPT41: 2, + LlmModel.GPT41_MINI: 1, LlmModel.GPT4O_MINI: 1, LlmModel.GPT4O: 3, LlmModel.GPT4_TURBO: 10, LlmModel.GPT3_5_TURBO: 1, + LlmModel.CLAUDE_4_1_OPUS: 21, + LlmModel.CLAUDE_4_OPUS: 21, + LlmModel.CLAUDE_4_SONNET: 5, + LlmModel.CLAUDE_3_7_SONNET: 5, LlmModel.CLAUDE_3_5_SONNET: 4, + LlmModel.CLAUDE_3_5_HAIKU: 1, # $0.80 / $4.00 LlmModel.CLAUDE_3_HAIKU: 1, + LlmModel.AIML_API_QWEN2_5_72B: 1, + LlmModel.AIML_API_LLAMA3_1_70B: 1, + LlmModel.AIML_API_LLAMA3_3_70B: 1, + LlmModel.AIML_API_META_LLAMA_3_1_70B: 1, + LlmModel.AIML_API_LLAMA_3_2_3B: 1, LlmModel.LLAMA3_8B: 1, LlmModel.LLAMA3_70B: 1, - LlmModel.MIXTRAL_8X7B: 1, - LlmModel.GEMMA_7B: 1, LlmModel.GEMMA2_9B: 1, - LlmModel.LLAMA3_1_405B: 1, - LlmModel.LLAMA3_1_70B: 1, + LlmModel.LLAMA3_3_70B: 1, # $0.59 / $0.79 LlmModel.LLAMA3_1_8B: 1, + LlmModel.OLLAMA_LLAMA3_3: 1, + LlmModel.OLLAMA_LLAMA3_2: 1, LlmModel.OLLAMA_LLAMA3_8B: 1, LlmModel.OLLAMA_LLAMA3_405B: 1, + LlmModel.DEEPSEEK_LLAMA_70B: 1, # ? / ? LlmModel.OLLAMA_DOLPHIN: 1, - LlmModel.GEMINI_FLASH_1_5_8B: 1, - LlmModel.GROK_BETA: 5, + LlmModel.OPENAI_GPT_OSS_120B: 1, + LlmModel.OPENAI_GPT_OSS_20B: 1, + LlmModel.GEMINI_FLASH_1_5: 1, + LlmModel.GEMINI_2_5_PRO: 4, LlmModel.MISTRAL_NEMO: 1, LlmModel.COHERE_COMMAND_R_08_2024: 1, LlmModel.COHERE_COMMAND_R_PLUS_08_2024: 3, - LlmModel.EVA_QWEN_2_5_32B: 1, LlmModel.DEEPSEEK_CHAT: 2, - LlmModel.PERPLEXITY_LLAMA_3_1_SONAR_LARGE_128K_ONLINE: 1, - LlmModel.QWEN_QWQ_32B_PREVIEW: 2, + LlmModel.PERPLEXITY_SONAR: 1, + LlmModel.PERPLEXITY_SONAR_PRO: 5, + LlmModel.PERPLEXITY_SONAR_DEEP_RESEARCH: 10, LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_405B: 1, LlmModel.NOUSRESEARCH_HERMES_3_LLAMA_3_1_70B: 1, LlmModel.AMAZON_NOVA_LITE_V1: 1, @@ -70,6 +108,25 @@ LlmModel.AMAZON_NOVA_PRO_V1: 1, LlmModel.MICROSOFT_WIZARDLM_2_8X22B: 1, LlmModel.GRYPHE_MYTHOMAX_L2_13B: 1, + LlmModel.META_LLAMA_4_SCOUT: 1, + LlmModel.META_LLAMA_4_MAVERICK: 1, + LlmModel.LLAMA_API_LLAMA_4_SCOUT: 1, + LlmModel.LLAMA_API_LLAMA4_MAVERICK: 1, + LlmModel.LLAMA_API_LLAMA3_3_8B: 1, + LlmModel.LLAMA_API_LLAMA3_3_70B: 1, + LlmModel.GROK_4: 9, + LlmModel.KIMI_K2: 1, + LlmModel.QWEN3_235B_A22B_THINKING: 1, + LlmModel.QWEN3_CODER: 9, + LlmModel.GEMINI_2_5_FLASH: 1, + LlmModel.GEMINI_2_0_FLASH: 1, + LlmModel.GEMINI_2_5_FLASH_LITE_PREVIEW: 1, + LlmModel.GEMINI_2_0_FLASH_LITE: 1, + LlmModel.DEEPSEEK_R1_0528: 1, + # v0 by Vercel models + LlmModel.V0_1_5_MD: 1, + LlmModel.V0_1_5_LG: 2, + LlmModel.V0_1_0_MD: 1, } for model in LlmModel: @@ -142,6 +199,57 @@ for model, cost in MODEL_COST.items() if MODEL_METADATA[model].provider == "open_router" ] + # Llama API Models + + [ + BlockCost( + cost_type=BlockCostType.RUN, + cost_filter={ + "model": model, + "credentials": { + "id": llama_api_credentials.id, + "provider": llama_api_credentials.provider, + "type": llama_api_credentials.type, + }, + }, + cost_amount=cost, + ) + for model, cost in MODEL_COST.items() + if MODEL_METADATA[model].provider == "llama_api" + ] + # v0 by Vercel Models + + [ + BlockCost( + cost_type=BlockCostType.RUN, + cost_filter={ + "model": model, + "credentials": { + "id": v0_credentials.id, + "provider": v0_credentials.provider, + "type": v0_credentials.type, + }, + }, + cost_amount=cost, + ) + for model, cost in MODEL_COST.items() + if MODEL_METADATA[model].provider == "v0" + ] + # AI/ML Api Models + + [ + BlockCost( + cost_type=BlockCostType.RUN, + cost_filter={ + "model": model, + "credentials": { + "id": aiml_api_credentials.id, + "provider": aiml_api_credentials.provider, + "type": aiml_api_credentials.type, + }, + }, + cost_amount=cost, + ) + for model, cost in MODEL_COST.items() + if MODEL_METADATA[model].provider == "aiml_api" + ] ) # =============== This is the exhaustive list of cost for each Block =============== # @@ -199,7 +307,18 @@ "type": ideogram_credentials.type, } }, - ) + ), + BlockCost( + cost_amount=18, + cost_filter={ + "ideogram_model_name": "V_3", + "credentials": { + "id": ideogram_credentials.id, + "provider": ideogram_credentials.provider, + "type": ideogram_credentials.type, + }, + }, + ), ], AIShortformVideoCreatorBlock: [ BlockCost( @@ -225,6 +344,42 @@ }, ) ], + ReplicateModelBlock: [ + BlockCost( + cost_amount=10, + cost_filter={ + "credentials": { + "id": replicate_credentials.id, + "provider": replicate_credentials.provider, + "type": replicate_credentials.type, + } + }, + ) + ], + AIImageEditorBlock: [ + BlockCost( + cost_amount=10, + cost_filter={ + "model": FluxKontextModelName.PRO.api_name, + "credentials": { + "id": replicate_credentials.id, + "provider": replicate_credentials.provider, + "type": replicate_credentials.type, + }, + }, + ), + BlockCost( + cost_amount=20, + cost_filter={ + "model": FluxKontextModelName.MAX.api_name, + "credentials": { + "id": replicate_credentials.id, + "provider": replicate_credentials.provider, + "type": replicate_credentials.type, + }, + }, + ), + ], AIMusicGeneratorBlock: [ BlockCost( cost_amount=11, @@ -261,4 +416,101 @@ }, ) ], + GetLinkedinProfileBlock: [ + BlockCost( + cost_amount=1, + cost_filter={ + "credentials": { + "id": enrichlayer_credentials.id, + "provider": enrichlayer_credentials.provider, + "type": enrichlayer_credentials.type, + } + }, + ) + ], + LinkedinPersonLookupBlock: [ + BlockCost( + cost_amount=2, + cost_filter={ + "credentials": { + "id": enrichlayer_credentials.id, + "provider": enrichlayer_credentials.provider, + "type": enrichlayer_credentials.type, + } + }, + ) + ], + LinkedinRoleLookupBlock: [ + BlockCost( + cost_amount=3, + cost_filter={ + "credentials": { + "id": enrichlayer_credentials.id, + "provider": enrichlayer_credentials.provider, + "type": enrichlayer_credentials.type, + } + }, + ) + ], + GetLinkedinProfilePictureBlock: [ + BlockCost( + cost_amount=3, + cost_filter={ + "credentials": { + "id": enrichlayer_credentials.id, + "provider": enrichlayer_credentials.provider, + "type": enrichlayer_credentials.type, + } + }, + ) + ], + SmartDecisionMakerBlock: LLM_COST, + SearchOrganizationsBlock: [ + BlockCost( + cost_amount=2, + cost_filter={ + "credentials": { + "id": apollo_credentials.id, + "provider": apollo_credentials.provider, + "type": apollo_credentials.type, + } + }, + ) + ], + SearchPeopleBlock: [ + BlockCost( + cost_amount=10, + cost_filter={ + "enrich_info": False, + "credentials": { + "id": apollo_credentials.id, + "provider": apollo_credentials.provider, + "type": apollo_credentials.type, + }, + }, + ), + BlockCost( + cost_amount=20, + cost_filter={ + "enrich_info": True, + "credentials": { + "id": apollo_credentials.id, + "provider": apollo_credentials.provider, + "type": apollo_credentials.type, + }, + }, + ), + ], + GetPersonDetailBlock: [ + BlockCost( + cost_amount=1, + cost_filter={ + "credentials": { + "id": apollo_credentials.id, + "provider": apollo_credentials.provider, + "type": apollo_credentials.type, + } + }, + ) + ], } diff --git a/autogpt_platform/backend/backend/data/cost.py b/autogpt_platform/backend/backend/data/cost.py index 3e3e9bae65aa..2318d72d7a23 100644 --- a/autogpt_platform/backend/backend/data/cost.py +++ b/autogpt_platform/backend/backend/data/cost.py @@ -10,7 +10,6 @@ class BlockCostType(str, Enum): RUN = "run" # cost X credits per run BYTE = "byte" # cost X credits per byte SECOND = "second" # cost X credits per second - DOLLAR = "dollar" # cost X dollars per run class BlockCost(BaseModel): diff --git a/autogpt_platform/backend/backend/data/credit.py b/autogpt_platform/backend/backend/data/credit.py index b476f1f0d0b1..b83d6fbb564b 100644 --- a/autogpt_platform/backend/backend/data/credit.py +++ b/autogpt_platform/backend/backend/data/credit.py @@ -1,30 +1,106 @@ +import logging from abc import ABC, abstractmethod +from collections import defaultdict from datetime import datetime, timezone +from typing import Any, cast +import stripe from prisma import Json -from prisma.enums import CreditTransactionType +from prisma.enums import ( + CreditRefundRequestStatus, + CreditTransactionType, + NotificationType, + OnboardingStep, +) from prisma.errors import UniqueViolationError -from prisma.models import CreditTransaction +from prisma.models import CreditRefundRequest, CreditTransaction, User +from prisma.types import ( + CreditRefundRequestCreateInput, + CreditTransactionCreateInput, + CreditTransactionWhereInput, +) +from pydantic import BaseModel -from backend.data.block import Block, BlockInput, get_block +from backend.data import db from backend.data.block_cost_config import BLOCK_COSTS -from backend.data.cost import BlockCost, BlockCostType -from backend.util.settings import Config +from backend.data.cost import BlockCost +from backend.data.model import ( + AutoTopUpConfig, + RefundRequest, + TopUpType, + TransactionHistory, + UserTransaction, +) +from backend.data.notifications import NotificationEventModel, RefundRequestData +from backend.data.user import get_user_by_id, get_user_email_by_id +from backend.notifications.notifications import queue_notification_async +from backend.server.v2.admin.model import UserHistoryResponse +from backend.util.exceptions import InsufficientBalanceError +from backend.util.json import SafeJson +from backend.util.models import Pagination +from backend.util.retry import func_retry +from backend.util.settings import Settings -config = Config() +settings = Settings() +stripe.api_key = settings.secrets.stripe_api_key +logger = logging.getLogger(__name__) +base_url = settings.config.frontend_base_url or settings.config.platform_base_url + + +class UsageTransactionMetadata(BaseModel): + graph_exec_id: str | None = None + graph_id: str | None = None + node_id: str | None = None + node_exec_id: str | None = None + block_id: str | None = None + block: str | None = None + input: dict[str, Any] | None = None + reason: str | None = None class UserCreditBase(ABC): - def __init__(self, num_user_credits_refill: int): - self.num_user_credits_refill = num_user_credits_refill + @abstractmethod + async def get_credits(self, user_id: str) -> int: + """ + Get the current credits for the user. + + Returns: + int: The current credits for the user. + """ + pass @abstractmethod - async def get_or_refill_credit(self, user_id: str) -> int: + async def get_transaction_history( + self, + user_id: str, + transaction_count_limit: int, + transaction_time_ceiling: datetime | None = None, + transaction_type: str | None = None, + ) -> TransactionHistory: """ - Get the current credit for the user and refill if no transaction has been made in the current cycle. + Get the credit transactions for the user. + + Args: + user_id (str): The user ID. + transaction_count_limit (int): The transaction count limit. + transaction_time_ceiling (datetime): The upper bound of the transaction time. + transaction_type (str): The transaction type filter. Returns: - int: The current credit for the user. + TransactionHistory: The credit transactions for the user. + """ + pass + + @abstractmethod + async def get_refund_requests(self, user_id: str) -> list[RefundRequest]: + """ + Get the refund requests for the user. + + Args: + user_id (str): The user ID. + + Returns: + list[RefundRequest]: The refund requests for the user. """ pass @@ -32,25 +108,19 @@ async def get_or_refill_credit(self, user_id: str) -> int: async def spend_credits( self, user_id: str, - user_credit: int, - block_id: str, - input_data: BlockInput, - data_size: float, - run_time: float, + cost: int, + metadata: UsageTransactionMetadata, ) -> int: """ - Spend the credits for the user based on the block usage. + Spend the credits for the user based on the cost. Args: user_id (str): The user ID. - user_credit (int): The current credit for the user. - block_id (str): The block ID. - input_data (BlockInput): The input data for the block. - data_size (float): The size of the data being processed. - run_time (float): The time taken to run the block. + cost (int): The cost to spend. + metadata (UsageTransactionMetadata): The metadata of the transaction. Returns: - int: amount of credit spent + int: The remaining balance. """ pass @@ -65,156 +135,832 @@ async def top_up_credits(self, user_id: str, amount: int): """ pass + @abstractmethod + async def onboarding_reward(self, user_id: str, credits: int, step: OnboardingStep): + """ + Reward the user with credits for completing an onboarding step. + Won't reward if the user has already received credits for the step. -class UserCredit(UserCreditBase): - async def get_or_refill_credit(self, user_id: str) -> int: - cur_time = self.time_now() - cur_month = cur_time.replace(day=1, hour=0, minute=0, second=0, microsecond=0) - nxt_month = ( - cur_month.replace(month=cur_month.month + 1) - if cur_month.month < 12 - else cur_month.replace(year=cur_month.year + 1, month=1) + Args: + user_id (str): The user ID. + step (OnboardingStep): The onboarding step. + """ + pass + + @abstractmethod + async def top_up_intent(self, user_id: str, amount: int) -> str: + """ + Create a payment intent to top up the credits for the user. + + Args: + user_id (str): The user ID. + amount (int): The amount of credits to top up. + + Returns: + str: The redirect url to the payment page. + """ + pass + + @abstractmethod + async def top_up_refund( + self, user_id: str, transaction_key: str, metadata: dict[str, str] + ) -> int: + """ + Refund the top-up transaction for the user. + + Args: + user_id (str): The user ID. + transaction_key (str): The top-up transaction key to refund. + metadata (dict[str, str]): The metadata of the refund. + + Returns: + int: The amount refunded. + """ + pass + + @abstractmethod + async def deduct_credits( + self, + request: stripe.Refund | stripe.Dispute, + ): + """ + Deduct the credits for the user based on the dispute or refund of the top-up. + + Args: + request (stripe.Refund | stripe.Dispute): The refund or dispute request. + """ + pass + + @abstractmethod + async def handle_dispute(self, dispute: stripe.Dispute): + """ + Handle the dispute for the user based on the dispute request. + + Args: + dispute (stripe.Dispute): The dispute request. + """ + pass + + @abstractmethod + async def fulfill_checkout( + self, *, session_id: str | None = None, user_id: str | None = None + ): + """ + Fulfill the Stripe checkout session. + + Args: + session_id (str | None): The checkout session ID. Will try to fulfill most recent if None. + user_id (str | None): The user ID must be provided if session_id is None. + """ + pass + + @staticmethod + async def create_billing_portal_session(user_id: str) -> str: + session = stripe.billing_portal.Session.create( + customer=await get_stripe_customer_id(user_id), + return_url=base_url + "/profile/credits", ) + return session.url + + @staticmethod + def time_now() -> datetime: + return datetime.now(timezone.utc) + + # ====== Transaction Helper Methods ====== # + # Any modifications to the transaction table should only be done through these methods # - user_credit = await CreditTransaction.prisma().group_by( + async def _get_credits(self, user_id: str) -> tuple[int, datetime]: + """ + Returns the current balance of the user & the latest balance snapshot time. + """ + top_time = self.time_now() + snapshot = await CreditTransaction.prisma().find_first( + where={ + "userId": user_id, + "createdAt": {"lte": top_time}, + "isActive": True, + "NOT": [{"runningBalance": None}], + }, + order={"createdAt": "desc"}, + ) + datetime_min = datetime.min.replace(tzinfo=timezone.utc) + snapshot_balance = snapshot.runningBalance or 0 if snapshot else 0 + snapshot_time = snapshot.createdAt if snapshot else datetime_min + + # Get transactions after the snapshot, this should not exist, but just in case. + transactions = await CreditTransaction.prisma().group_by( by=["userId"], sum={"amount": True}, + max={"createdAt": True}, where={ "userId": user_id, - "createdAt": {"gte": cur_month, "lt": nxt_month}, + "createdAt": { + "gt": snapshot_time, + "lte": top_time, + }, "isActive": True, }, ) + transaction_balance = ( + int(transactions[0].get("_sum", {}).get("amount", 0) + snapshot_balance) + if transactions + else snapshot_balance + ) + transaction_time = ( + datetime.fromisoformat( + str(transactions[0].get("_max", {}).get("createdAt", datetime_min)) + ) + if transactions + else snapshot_time + ) + return transaction_balance, transaction_time - if user_credit: - credit_sum = user_credit[0].get("_sum") or {} - return credit_sum.get("amount", 0) + @func_retry + async def _enable_transaction( + self, + transaction_key: str, + user_id: str, + metadata: Json, + new_transaction_key: str | None = None, + ): + transaction = await CreditTransaction.prisma().find_first_or_raise( + where={"transactionKey": transaction_key, "userId": user_id} + ) + if transaction.isActive: + return - key = f"MONTHLY-CREDIT-TOP-UP-{cur_month}" + async with db.locked_transaction(f"usr_trx_{user_id}"): - try: - await CreditTransaction.prisma().create( + transaction = await CreditTransaction.prisma().find_first_or_raise( + where={"transactionKey": transaction_key, "userId": user_id} + ) + if transaction.isActive: + return + + user_balance, _ = await self._get_credits(user_id) + await CreditTransaction.prisma().update( + where={ + "creditTransactionIdentifier": { + "transactionKey": transaction_key, + "userId": user_id, + } + }, data={ - "amount": self.num_user_credits_refill, - "type": CreditTransactionType.TOP_UP, - "userId": user_id, - "transactionKey": key, + "transactionKey": new_transaction_key or transaction_key, + "isActive": True, + "runningBalance": user_balance + transaction.amount, "createdAt": self.time_now(), - } + "metadata": metadata, + }, ) - except UniqueViolationError: - pass # Already refilled this month - return self.num_user_credits_refill + async def _add_transaction( + self, + user_id: str, + amount: int, + transaction_type: CreditTransactionType, + is_active: bool = True, + transaction_key: str | None = None, + ceiling_balance: int | None = None, + fail_insufficient_credits: bool = True, + metadata: Json = SafeJson({}), + ) -> tuple[int, str]: + """ + Add a new transaction for the user. + This is the only method that should be used to add a new transaction. - @staticmethod - def time_now(): - return datetime.now(timezone.utc) + Args: + user_id (str): The user ID. + amount (int): The amount of credits to add. + transaction_type (CreditTransactionType): The type of transaction. + is_active (bool): Whether the transaction is active or needs to be manually activated through _enable_transaction. + transaction_key (str | None): The transaction key. Avoids adding transaction if the key already exists. + ceiling_balance (int | None): The ceiling balance. Avoids adding more credits if the balance is already above the ceiling. + fail_insufficient_credits (bool): Whether to fail if the user has insufficient credits. + metadata (Json): The metadata of the transaction. - def _block_usage_cost( - self, - block: Block, - input_data: BlockInput, - data_size: float, - run_time: float, - ) -> tuple[int, BlockInput]: - block_costs = BLOCK_COSTS.get(type(block)) - if not block_costs: - return 0, {} - - for block_cost in block_costs: - if not self._is_cost_filter_match(block_cost.cost_filter, input_data): - continue - - if block_cost.cost_type == BlockCostType.RUN: - return block_cost.cost_amount, block_cost.cost_filter - - if block_cost.cost_type == BlockCostType.SECOND: - return ( - int(run_time * block_cost.cost_amount), - block_cost.cost_filter, - ) + Returns: + tuple[int, str]: The new balance & the transaction key. + """ + async with db.locked_transaction(f"usr_trx_{user_id}"): + # Get latest balance snapshot + user_balance, _ = await self._get_credits(user_id) - if block_cost.cost_type == BlockCostType.BYTE: - return ( - int(data_size * block_cost.cost_amount), - block_cost.cost_filter, + if ceiling_balance and amount > 0 and user_balance >= ceiling_balance: + raise ValueError( + f"You already have enough balance of ${user_balance/100}, top-up is not required when you already have at least ${ceiling_balance/100}" ) - return 0, {} + if amount < 0 and user_balance + amount < 0: + if fail_insufficient_credits: + raise InsufficientBalanceError( + message=f"Insufficient balance of ${user_balance/100}, where this will cost ${abs(amount)/100}", + user_id=user_id, + balance=user_balance, + amount=amount, + ) - def _is_cost_filter_match( - self, cost_filter: BlockInput, input_data: BlockInput - ) -> bool: - """ - Filter rules: - - If costFilter is an object, then check if costFilter is the subset of inputValues - - Otherwise, check if costFilter is equal to inputValues. - - Undefined, null, and empty string are considered as equal. - """ - if not isinstance(cost_filter, dict) or not isinstance(input_data, dict): - return cost_filter == input_data + amount = min(-user_balance, 0) + + # Create the transaction + transaction_data: CreditTransactionCreateInput = { + "userId": user_id, + "amount": amount, + "runningBalance": user_balance + amount, + "type": transaction_type, + "metadata": metadata, + "isActive": is_active, + "createdAt": self.time_now(), + } + if transaction_key: + transaction_data["transactionKey"] = transaction_key + tx = await CreditTransaction.prisma().create(data=transaction_data) + return user_balance + amount, tx.transactionKey - return all( - (not input_data.get(k) and not v) - or (input_data.get(k) and self._is_cost_filter_match(v, input_data[k])) - for k, v in cost_filter.items() + +class UserCredit(UserCreditBase): + + async def _send_refund_notification( + self, + notification_request: RefundRequestData, + notification_type: NotificationType, + ): + await queue_notification_async( + NotificationEventModel( + user_id=notification_request.user_id, + type=notification_type, + data=notification_request, + ) ) async def spend_credits( self, user_id: str, - user_credit: int, - block_id: str, - input_data: BlockInput, - data_size: float, - run_time: float, - validate_balance: bool = True, + cost: int, + metadata: UsageTransactionMetadata, ) -> int: - block = get_block(block_id) - if not block: - raise ValueError(f"Block not found: {block_id}") + if cost == 0: + return 0 - cost, matching_filter = self._block_usage_cost( - block=block, input_data=input_data, data_size=data_size, run_time=run_time + balance, _ = await self._add_transaction( + user_id=user_id, + amount=-cost, + transaction_type=CreditTransactionType.USAGE, + metadata=SafeJson(metadata.model_dump()), ) - if cost <= 0: - return 0 - if validate_balance and user_credit < cost: - raise ValueError(f"Insufficient credit: {user_credit} < {cost}") + # Auto top-up if balance is below threshold. + auto_top_up = await get_auto_top_up(user_id) + if auto_top_up.threshold and balance < auto_top_up.threshold: + try: + await self._top_up_credits( + user_id=user_id, + amount=auto_top_up.amount, + # Avoid multiple auto top-ups within the same graph execution. + key=f"AUTO-TOP-UP-{user_id}-{metadata.graph_exec_id}", + ceiling_balance=auto_top_up.threshold, + top_up_type=TopUpType.AUTO, + ) + except Exception as e: + # Failed top-up is not critical, we can move on. + logger.error( + f"Auto top-up failed for user {user_id}, balance: {balance}, amount: {auto_top_up.amount}, error: {e}" + ) - await CreditTransaction.prisma().create( - data={ + return balance + + async def top_up_credits( + self, + user_id: str, + amount: int, + top_up_type: TopUpType = TopUpType.UNCATEGORIZED, + ): + await self._top_up_credits( + user_id=user_id, amount=amount, top_up_type=top_up_type + ) + + async def onboarding_reward(self, user_id: str, credits: int, step: OnboardingStep): + try: + await self._add_transaction( + user_id=user_id, + amount=credits, + transaction_type=CreditTransactionType.GRANT, + transaction_key=f"REWARD-{user_id}-{step.value}", + metadata=SafeJson( + {"reason": f"Reward for completing {step.value} onboarding step."} + ), + ) + except UniqueViolationError: + # Already rewarded for this step + pass + + async def top_up_refund( + self, user_id: str, transaction_key: str, metadata: dict[str, str] + ) -> int: + transaction = await CreditTransaction.prisma().find_first_or_raise( + where={ + "transactionKey": transaction_key, "userId": user_id, - "amount": -cost, - "type": CreditTransactionType.USAGE, - "blockId": block.id, - "metadata": Json( - { - "block": block.name, - "input": matching_filter, - } + "isActive": True, + "type": CreditTransactionType.TOP_UP, + } + ) + balance = await self.get_credits(user_id) + amount = transaction.amount + refund_key_format = settings.config.refund_request_time_key_format + refund_key = f"{transaction.createdAt.strftime(refund_key_format)}-{user_id}" + + try: + refund_request = await CreditRefundRequest.prisma().create( + data=CreditRefundRequestCreateInput( + id=refund_key, + transactionKey=transaction_key, + userId=user_id, + amount=amount, + reason=metadata.get("reason", ""), + status=CreditRefundRequestStatus.PENDING, + result="The refund request is under review.", + ) + ) + except UniqueViolationError: + raise ValueError( + "Unable to request a refund for this transaction, the request of the top-up transaction within the same week has already been made." + ) + + if amount - balance > settings.config.refund_credit_tolerance_threshold: + user_data = await get_user_by_id(user_id) + await self._send_refund_notification( + RefundRequestData( + user_id=user_id, + user_name=user_data.name or "AutoGPT Platform User", + user_email=user_data.email, + transaction_id=transaction_key, + refund_request_id=refund_request.id, + reason=refund_request.reason, + amount=amount, + balance=balance, ), - "createdAt": self.time_now(), + NotificationType.REFUND_REQUEST, + ) + return 0 # Register the refund request for manual approval. + + # Auto refund the top-up. + refund = stripe.Refund.create(payment_intent=transaction_key, metadata=metadata) + return refund.amount + + async def deduct_credits(self, request: stripe.Refund | stripe.Dispute): + if isinstance(request, stripe.Refund) and request.status != "succeeded": + logger.warning( + f"Skip processing refund #{request.id} with status {request.status}" + ) + return + + if isinstance(request, stripe.Dispute) and request.status != "lost": + logger.warning( + f"Skip processing dispute #{request.id} with status {request.status}" + ) + return + + transaction = await CreditTransaction.prisma().find_first_or_raise( + where={ + "transactionKey": str(request.payment_intent), + "isActive": True, + "type": CreditTransactionType.TOP_UP, } ) - return cost + if request.amount <= 0 or request.amount > transaction.amount: + raise AssertionError( + f"Invalid amount to deduct ${request.amount/100} from ${transaction.amount/100} top-up" + ) - async def top_up_credits(self, user_id: str, amount: int): - await CreditTransaction.prisma().create( + balance, _ = await self._add_transaction( + user_id=transaction.userId, + amount=-request.amount, + transaction_type=CreditTransactionType.REFUND, + transaction_key=request.id, + metadata=SafeJson(request), + fail_insufficient_credits=False, + ) + + # Update the result of the refund request if it exists. + await CreditRefundRequest.prisma().update_many( + where={ + "userId": transaction.userId, + "transactionKey": transaction.transactionKey, + }, data={ - "userId": user_id, - "amount": amount, + "amount": request.amount, + "status": CreditRefundRequestStatus.APPROVED, + "result": "The refund request has been approved, the amount will be credited back to your account.", + }, + ) + + user_data = await get_user_by_id(transaction.userId) + await self._send_refund_notification( + RefundRequestData( + user_id=user_data.id, + user_name=user_data.name or "AutoGPT Platform User", + user_email=user_data.email, + transaction_id=transaction.transactionKey, + refund_request_id=request.id, + reason=str(request.reason or "-"), + amount=transaction.amount, + balance=balance, + ), + NotificationType.REFUND_PROCESSED, + ) + + async def handle_dispute(self, dispute: stripe.Dispute): + transaction = await CreditTransaction.prisma().find_first_or_raise( + where={ + "transactionKey": str(dispute.payment_intent), + "isActive": True, "type": CreditTransactionType.TOP_UP, - "createdAt": self.time_now(), } ) + user_id = transaction.userId + amount = dispute.amount + balance = await self.get_credits(user_id) + + # If the user has enough balance, just let them win the dispute. + if balance - amount >= settings.config.refund_credit_tolerance_threshold: + logger.warning(f"Accepting dispute from {user_id} for ${amount/100}") + dispute.close() + return + + logger.warning( + f"Adding extra info for dispute from {user_id} for ${amount/100}" + ) + # Retrieve recent transaction history to support our evidence. + # This provides a concise timeline that shows service usage and proper credit application. + transaction_history = await self.get_transaction_history( + user_id, transaction_count_limit=None + ) + user = await get_user_by_id(user_id) + + # Build a comprehensive explanation message that includes: + # - Confirmation that the top-up transaction was processed and credits were applied. + # - A summary of recent transaction history. + # - An explanation that the funds were used to render the agreed service. + evidence_text = ( + f"The top-up transaction of ${transaction.amount / 100:.2f} was processed successfully, and the corresponding credits " + "were applied to the user’s account. Our records confirm that the funds were utilized for the intended services. " + "Below is a summary of recent transaction activity:\n" + ) + for tx in transaction_history.transactions: + if tx.transaction_key == transaction.transactionKey: + additional_comment = ( + " [This top-up transaction is the subject of the dispute]." + ) + else: + additional_comment = "" + + evidence_text += ( + f"- {tx.description}: Amount ${tx.amount / 100:.2f} on {tx.transaction_time.isoformat()}, " + f"resulting balance ${tx.running_balance / 100:.2f} {additional_comment}\n" + ) + evidence_text += ( + "\nThis evidence demonstrates that the transaction was authorized and that the charged amount was used to render the service as agreed." + "\nAdditionally, we provide an automated refund functionality, so the user could have used it if they were not satisfied with the service. " + ) + evidence: stripe.Dispute.ModifyParamsEvidence = { + "product_description": "AutoGPT Platform Credits", + "customer_email_address": user.email, + "uncategorized_text": evidence_text[:20000], + } + stripe.Dispute.modify(dispute.id, evidence=evidence) + + async def _top_up_credits( + self, + user_id: str, + amount: int, + key: str | None = None, + ceiling_balance: int | None = None, + top_up_type: TopUpType = TopUpType.UNCATEGORIZED, + metadata: dict | None = None, + ): + # init metadata, without sharing it with the world + metadata = metadata or {} + if not metadata["reason"]: + match top_up_type: + case TopUpType.MANUAL: + metadata["reason"] = {"reason": f"Top up credits for {user_id}"} + case TopUpType.AUTO: + metadata["reason"] = { + "reason": f"Auto top up credits for {user_id}" + } + case _: + metadata["reason"] = { + "reason": f"Top up reason unknown for {user_id}" + } + + if amount < 0: + raise ValueError(f"Top up amount must not be negative: {amount}") + + if key is not None and ( + await CreditTransaction.prisma().find_first( + where={"transactionKey": key, "userId": user_id} + ) + ): + raise ValueError(f"Transaction key {key} already exists for user {user_id}") + + if amount == 0: + transaction_type = CreditTransactionType.CARD_CHECK + else: + transaction_type = CreditTransactionType.TOP_UP + + _, transaction_key = await self._add_transaction( + user_id=user_id, + amount=amount, + transaction_type=transaction_type, + is_active=False, + transaction_key=key, + ceiling_balance=ceiling_balance, + metadata=(SafeJson(metadata)), + ) + + customer_id = await get_stripe_customer_id(user_id) + + payment_methods = stripe.PaymentMethod.list(customer=customer_id, type="card") + if not payment_methods: + raise ValueError("No payment method found, please add it on the platform.") + + successful_transaction = None + new_transaction_key = None + for payment_method in payment_methods: + if transaction_type == CreditTransactionType.CARD_CHECK: + setup_intent = stripe.SetupIntent.create( + customer=customer_id, + usage="off_session", + confirm=True, + payment_method=payment_method.id, + automatic_payment_methods={ + "enabled": True, + "allow_redirects": "never", + }, + ) + if setup_intent.status == "succeeded": + successful_transaction = SafeJson({"setup_intent": setup_intent}) + new_transaction_key = setup_intent.id + break + else: + payment_intent = stripe.PaymentIntent.create( + amount=amount, + currency="usd", + description="AutoGPT Platform Credits", + customer=customer_id, + off_session=True, + confirm=True, + payment_method=payment_method.id, + automatic_payment_methods={ + "enabled": True, + "allow_redirects": "never", + }, + ) + if payment_intent.status == "succeeded": + successful_transaction = SafeJson( + {"payment_intent": payment_intent} + ) + new_transaction_key = payment_intent.id + break + + if not successful_transaction: + raise ValueError( + f"Out of {len(payment_methods)} payment methods tried, none is supported" + ) + + await self._enable_transaction( + transaction_key=transaction_key, + new_transaction_key=new_transaction_key, + user_id=user_id, + metadata=successful_transaction, + ) + + async def top_up_intent(self, user_id: str, amount: int) -> str: + if amount < 500 or amount % 100 != 0: + raise ValueError( + f"Top up amount must be at least 500 credits and multiple of 100 but is {amount}" + ) + + # Create checkout session + # https://docs.stripe.com/checkout/quickstart?client=react + # unit_amount param is always in the smallest currency unit (so cents for usd) + # which is equal to amount of credits + checkout_session = stripe.checkout.Session.create( + customer=await get_stripe_customer_id(user_id), + line_items=[ + { + "price_data": { + "currency": "usd", + "product_data": { + "name": "AutoGPT Platform Credits", + }, + "unit_amount": amount, + }, + "quantity": 1, + } + ], + mode="payment", + ui_mode="hosted", + payment_intent_data={"setup_future_usage": "off_session"}, + saved_payment_method_options={"payment_method_save": "enabled"}, + success_url=base_url + "/profile/credits?topup=success", + cancel_url=base_url + "/profile/credits?topup=cancel", + allow_promotion_codes=True, + ) + + await self._add_transaction( + user_id=user_id, + amount=amount, + transaction_type=CreditTransactionType.TOP_UP, + transaction_key=checkout_session.id, + is_active=False, + metadata=SafeJson(checkout_session), + ) + + return checkout_session.url or "" + + # https://docs.stripe.com/checkout/fulfillment + async def fulfill_checkout( + self, *, session_id: str | None = None, user_id: str | None = None + ): + if (not session_id and not user_id) or (session_id and user_id): + raise ValueError("Either session_id or user_id must be provided") + + # Retrieve CreditTransaction + find_filter: CreditTransactionWhereInput = { + "type": CreditTransactionType.TOP_UP, + "isActive": False, + "amount": {"gt": 0}, + } + if session_id: + find_filter["transactionKey"] = session_id + if user_id: + find_filter["userId"] = user_id + + # Find the most recent inactive top-up transaction + credit_transaction = await CreditTransaction.prisma().find_first( + where=find_filter, + order={"createdAt": "desc"}, + ) + + # This can be called multiple times for one id, so ignore if already fulfilled + if not credit_transaction: + return + + # If the transaction is not a checkout session, then skip the fulfillment + if not credit_transaction.transactionKey.startswith("cs_"): + return + + # Retrieve the Checkout Session from the API + checkout_session = stripe.checkout.Session.retrieve( + credit_transaction.transactionKey, + expand=["payment_intent"], + ) + + # Check the Checkout Session's payment_status property + # to determine if fulfillment should be performed + if checkout_session.payment_status in ["paid", "no_payment_required"]: + if payment_intent := checkout_session.payment_intent: + assert isinstance(payment_intent, stripe.PaymentIntent) + new_transaction_key = payment_intent.id + else: + new_transaction_key = None + + await self._enable_transaction( + transaction_key=credit_transaction.transactionKey, + new_transaction_key=new_transaction_key, + user_id=credit_transaction.userId, + metadata=SafeJson(checkout_session), + ) + + async def get_credits(self, user_id: str) -> int: + balance, _ = await self._get_credits(user_id) + return balance + + async def get_transaction_history( + self, + user_id: str, + transaction_count_limit: int | None = 100, + transaction_time_ceiling: datetime | None = None, + transaction_type: str | None = None, + ) -> TransactionHistory: + transactions_filter: CreditTransactionWhereInput = { + "userId": user_id, + "isActive": True, + } + if transaction_time_ceiling: + transaction_time_ceiling = transaction_time_ceiling.replace( + tzinfo=timezone.utc + ) + transactions_filter["createdAt"] = {"lt": transaction_time_ceiling} + if transaction_type: + transactions_filter["type"] = CreditTransactionType[transaction_type] + transactions = await CreditTransaction.prisma().find_many( + where=transactions_filter, + order={"createdAt": "desc"}, + take=transaction_count_limit, + ) + + # doesn't fill current_balance, reason, user_email, admin_email, or extra_data + grouped_transactions: dict[str, UserTransaction] = defaultdict( + lambda: UserTransaction(user_id=user_id) + ) + tx_time = None + for t in transactions: + metadata = ( + UsageTransactionMetadata.model_validate(t.metadata) + if t.metadata + else UsageTransactionMetadata() + ) + tx_time = t.createdAt.replace(tzinfo=timezone.utc) + + if t.type == CreditTransactionType.USAGE and metadata.graph_exec_id: + gt = grouped_transactions[metadata.graph_exec_id] + gid = metadata.graph_id[:8] if metadata.graph_id else "UNKNOWN" + gt.description = f"Graph #{gid} Execution" + + gt.usage_node_count += 1 + gt.usage_start_time = min(gt.usage_start_time, tx_time) + gt.usage_execution_id = metadata.graph_exec_id + gt.usage_graph_id = metadata.graph_id + else: + gt = grouped_transactions[t.transactionKey] + gt.description = f"{t.type} Transaction" + gt.transaction_key = t.transactionKey + + gt.amount += t.amount + gt.transaction_type = t.type + + if tx_time > gt.transaction_time: + gt.transaction_time = tx_time + gt.running_balance = t.runningBalance or 0 + + return TransactionHistory( + transactions=list(grouped_transactions.values()), + next_transaction_time=( + tx_time if len(transactions) == transaction_count_limit else None + ), + ) + + async def get_refund_requests(self, user_id: str) -> list[RefundRequest]: + return [ + RefundRequest( + id=r.id, + user_id=r.userId, + transaction_key=r.transactionKey, + amount=r.amount, + reason=r.reason, + result=r.result, + status=r.status, + created_at=r.createdAt, + updated_at=r.updatedAt, + ) + for r in await CreditRefundRequest.prisma().find_many( + where={"userId": user_id}, + order={"createdAt": "desc"}, + ) + ] + + +class BetaUserCredit(UserCredit): + """ + This is a temporary class to handle the test user utilizing monthly credit refill. + TODO: Remove this class & its feature toggle. + """ + + def __init__(self, num_user_credits_refill: int): + self.num_user_credits_refill = num_user_credits_refill + + async def get_credits(self, user_id: str) -> int: + cur_time = self.time_now().date() + balance, snapshot_time = await self._get_credits(user_id) + if (snapshot_time.year, snapshot_time.month) == (cur_time.year, cur_time.month): + return balance + + try: + balance, _ = await self._add_transaction( + user_id=user_id, + amount=max(self.num_user_credits_refill - balance, 0), + transaction_type=CreditTransactionType.GRANT, + transaction_key=f"MONTHLY-CREDIT-TOP-UP-{cur_time}", + metadata=SafeJson({"reason": "Monthly credit refill"}), + ) + return balance + except UniqueViolationError: + # Already refilled this month + return (await self._get_credits(user_id))[0] class DisabledUserCredit(UserCreditBase): - async def get_or_refill_credit(self, *args, **kwargs) -> int: - return 0 + async def get_credits(self, *args, **kwargs) -> int: + return 100 + + async def get_transaction_history(self, *args, **kwargs) -> TransactionHistory: + return TransactionHistory(transactions=[], next_transaction_time=None) + + async def get_refund_requests(self, *args, **kwargs) -> list[RefundRequest]: + return [] async def spend_credits(self, *args, **kwargs) -> int: return 0 @@ -222,13 +968,145 @@ async def spend_credits(self, *args, **kwargs) -> int: async def top_up_credits(self, *args, **kwargs): pass + async def onboarding_reward(self, *args, **kwargs): + pass + + async def top_up_intent(self, *args, **kwargs) -> str: + return "" + + async def top_up_refund(self, *args, **kwargs) -> int: + return 0 + + async def deduct_credits(self, *args, **kwargs): + pass + + async def handle_dispute(self, *args, **kwargs): + pass + + async def fulfill_checkout(self, *args, **kwargs): + pass + def get_user_credit_model() -> UserCreditBase: - if config.enable_credit.lower() == "true": - return UserCredit(config.num_user_credits_refill) - else: - return DisabledUserCredit(0) + if not settings.config.enable_credit: + return DisabledUserCredit() + + if settings.config.enable_beta_monthly_credit: + return BetaUserCredit(settings.config.num_user_credits_refill) + + return UserCredit() def get_block_costs() -> dict[str, list[BlockCost]]: return {block().id: costs for block, costs in BLOCK_COSTS.items()} + + +async def get_stripe_customer_id(user_id: str) -> str: + user = await get_user_by_id(user_id) + + if user.stripe_customer_id: + return user.stripe_customer_id + + customer = stripe.Customer.create( + name=user.name or "", + email=user.email, + metadata={"user_id": user_id}, + ) + await User.prisma().update( + where={"id": user_id}, data={"stripeCustomerId": customer.id} + ) + return customer.id + + +async def set_auto_top_up(user_id: str, config: AutoTopUpConfig): + await User.prisma().update( + where={"id": user_id}, + data={"topUpConfig": SafeJson(config.model_dump())}, + ) + + +async def get_auto_top_up(user_id: str) -> AutoTopUpConfig: + user = await get_user_by_id(user_id) + + if not user.top_up_config: + return AutoTopUpConfig(threshold=0, amount=0) + + return AutoTopUpConfig.model_validate(user.top_up_config) + + +async def admin_get_user_history( + page: int = 1, + page_size: int = 20, + search: str | None = None, + transaction_filter: CreditTransactionType | None = None, +) -> UserHistoryResponse: + + if page < 1 or page_size < 1: + raise ValueError("Invalid pagination input") + + where_clause: CreditTransactionWhereInput = {} + if transaction_filter: + where_clause["type"] = transaction_filter + if search: + where_clause["OR"] = [ + {"userId": {"contains": search, "mode": "insensitive"}}, + {"User": {"is": {"email": {"contains": search, "mode": "insensitive"}}}}, + {"User": {"is": {"name": {"contains": search, "mode": "insensitive"}}}}, + ] + transactions = await CreditTransaction.prisma().find_many( + where=where_clause, + skip=(page - 1) * page_size, + take=page_size, + include={"User": True}, + order={"createdAt": "desc"}, + ) + total = await CreditTransaction.prisma().count(where=where_clause) + total_pages = (total + page_size - 1) // page_size + + history = [] + for tx in transactions: + admin_id = "" + admin_email = "" + reason = "" + + metadata: dict = cast(dict, tx.metadata) or {} + + if metadata: + admin_id = metadata.get("admin_id") + admin_email = ( + (await get_user_email_by_id(admin_id) or f"Unknown Admin: {admin_id}") + if admin_id + else "" + ) + reason = metadata.get("reason", "No reason provided") + + balance, last_update = await get_user_credit_model()._get_credits(tx.userId) + + history.append( + UserTransaction( + transaction_key=tx.transactionKey, + transaction_time=tx.createdAt, + transaction_type=tx.type, + amount=tx.amount, + current_balance=balance, + running_balance=tx.runningBalance or 0, + user_id=tx.userId, + user_email=( + tx.User.email + if tx.User + else (await get_user_by_id(tx.userId)).email + ), + reason=reason, + admin_email=admin_email, + extra_data=str(metadata), + ) + ) + return UserHistoryResponse( + history=history, + pagination=Pagination( + total_items=total, + total_pages=total_pages, + current_page=page, + page_size=page_size, + ), + ) diff --git a/autogpt_platform/backend/backend/data/credit_test.py b/autogpt_platform/backend/backend/data/credit_test.py new file mode 100644 index 000000000000..704939c74556 --- /dev/null +++ b/autogpt_platform/backend/backend/data/credit_test.py @@ -0,0 +1,143 @@ +from datetime import datetime, timezone + +import pytest +from prisma.enums import CreditTransactionType +from prisma.models import CreditTransaction + +from backend.blocks.llm import AITextGeneratorBlock +from backend.data.block import get_block +from backend.data.credit import BetaUserCredit, UsageTransactionMetadata +from backend.data.execution import NodeExecutionEntry, UserContext +from backend.data.user import DEFAULT_USER_ID +from backend.executor.utils import block_usage_cost +from backend.integrations.credentials_store import openai_credentials +from backend.util.test import SpinTestServer + +REFILL_VALUE = 1000 +user_credit = BetaUserCredit(REFILL_VALUE) + + +async def disable_test_user_transactions(): + await CreditTransaction.prisma().delete_many(where={"userId": DEFAULT_USER_ID}) + + +async def top_up(amount: int): + await user_credit._add_transaction( + DEFAULT_USER_ID, + amount, + CreditTransactionType.TOP_UP, + ) + + +async def spend_credits(entry: NodeExecutionEntry) -> int: + block = get_block(entry.block_id) + if not block: + raise RuntimeError(f"Block {entry.block_id} not found") + + cost, matching_filter = block_usage_cost(block=block, input_data=entry.inputs) + await user_credit.spend_credits( + entry.user_id, + cost, + UsageTransactionMetadata( + graph_exec_id=entry.graph_exec_id, + graph_id=entry.graph_id, + node_id=entry.node_id, + node_exec_id=entry.node_exec_id, + block_id=entry.block_id, + block=entry.block_id, + input=matching_filter, + reason=f"Ran block {entry.block_id} {block.name}", + ), + ) + + return cost + + +@pytest.mark.asyncio(loop_scope="session") +async def test_block_credit_usage(server: SpinTestServer): + await disable_test_user_transactions() + await top_up(100) + current_credit = await user_credit.get_credits(DEFAULT_USER_ID) + + spending_amount_1 = await spend_credits( + NodeExecutionEntry( + user_id=DEFAULT_USER_ID, + graph_id="test_graph", + node_id="test_node", + graph_exec_id="test_graph_exec", + node_exec_id="test_node_exec", + block_id=AITextGeneratorBlock().id, + inputs={ + "model": "gpt-4-turbo", + "credentials": { + "id": openai_credentials.id, + "provider": openai_credentials.provider, + "type": openai_credentials.type, + }, + }, + user_context=UserContext(timezone="UTC"), + ), + ) + assert spending_amount_1 > 0 + + spending_amount_2 = await spend_credits( + NodeExecutionEntry( + user_id=DEFAULT_USER_ID, + graph_id="test_graph", + node_id="test_node", + graph_exec_id="test_graph_exec", + node_exec_id="test_node_exec", + block_id=AITextGeneratorBlock().id, + inputs={"model": "gpt-4-turbo", "api_key": "owned_api_key"}, + user_context=UserContext(timezone="UTC"), + ), + ) + assert spending_amount_2 == 0 + + new_credit = await user_credit.get_credits(DEFAULT_USER_ID) + assert new_credit == current_credit - spending_amount_1 - spending_amount_2 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_block_credit_top_up(server: SpinTestServer): + await disable_test_user_transactions() + current_credit = await user_credit.get_credits(DEFAULT_USER_ID) + + await top_up(100) + + new_credit = await user_credit.get_credits(DEFAULT_USER_ID) + assert new_credit == current_credit + 100 + + +@pytest.mark.asyncio(loop_scope="session") +async def test_block_credit_reset(server: SpinTestServer): + await disable_test_user_transactions() + month1 = 1 + month2 = 2 + + # set the calendar to month 2 but use current time from now + user_credit.time_now = lambda: datetime.now(timezone.utc).replace( + month=month2, day=1 + ) + month2credit = await user_credit.get_credits(DEFAULT_USER_ID) + + # Month 1 result should only affect month 1 + user_credit.time_now = lambda: datetime.now(timezone.utc).replace( + month=month1, day=1 + ) + month1credit = await user_credit.get_credits(DEFAULT_USER_ID) + await top_up(100) + assert await user_credit.get_credits(DEFAULT_USER_ID) == month1credit + 100 + + # Month 2 balance is unaffected + user_credit.time_now = lambda: datetime.now(timezone.utc).replace( + month=month2, day=1 + ) + assert await user_credit.get_credits(DEFAULT_USER_ID) == month2credit + + +@pytest.mark.asyncio(loop_scope="session") +async def test_credit_refill(server: SpinTestServer): + await disable_test_user_transactions() + balance = await user_credit.get_credits(DEFAULT_USER_ID) + assert balance == REFILL_VALUE diff --git a/autogpt_platform/backend/backend/data/db.py b/autogpt_platform/backend/backend/data/db.py index d18942ccfa27..9ac734fa71a5 100644 --- a/autogpt_platform/backend/backend/data/db.py +++ b/autogpt_platform/backend/backend/data/db.py @@ -1,6 +1,7 @@ import logging import os from contextlib import asynccontextmanager +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse from uuid import uuid4 from dotenv import load_dotenv @@ -14,11 +15,44 @@ PRISMA_SCHEMA = os.getenv("PRISMA_SCHEMA", "schema.prisma") os.environ["PRISMA_SCHEMA_PATH"] = PRISMA_SCHEMA -prisma = Prisma(auto_register=True) + +def add_param(url: str, key: str, value: str) -> str: + p = urlparse(url) + qs = dict(parse_qsl(p.query)) + qs[key] = value + return urlunparse(p._replace(query=urlencode(qs))) + + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://localhost:5432") + +CONN_LIMIT = os.getenv("DB_CONNECTION_LIMIT") +if CONN_LIMIT: + DATABASE_URL = add_param(DATABASE_URL, "connection_limit", CONN_LIMIT) + +CONN_TIMEOUT = os.getenv("DB_CONNECT_TIMEOUT") +if CONN_TIMEOUT: + DATABASE_URL = add_param(DATABASE_URL, "connect_timeout", CONN_TIMEOUT) + +POOL_TIMEOUT = os.getenv("DB_POOL_TIMEOUT") +if POOL_TIMEOUT: + DATABASE_URL = add_param(DATABASE_URL, "pool_timeout", POOL_TIMEOUT) + +HTTP_TIMEOUT = int(POOL_TIMEOUT) if POOL_TIMEOUT else None + +prisma = Prisma( + auto_register=True, + http={"timeout": HTTP_TIMEOUT}, + datasource={"url": DATABASE_URL}, +) + logger = logging.getLogger(__name__) +def is_connected(): + return prisma.is_connected() + + @conn_retry("Prisma", "Acquiring connection") async def connect(): if prisma.is_connected(): @@ -31,10 +65,10 @@ async def connect(): # Connection acquired from a pool like Supabase somehow still possibly allows # the db client obtains a connection but still reject query connection afterward. - try: - await prisma.execute_raw("SELECT 1") - except Exception as e: - raise ConnectionError("Failed to connect to Prisma.") from e + # try: + # await prisma.execute_raw("SELECT 1") + # except Exception as e: + # raise ConnectionError("Failed to connect to Prisma.") from e @conn_retry("Prisma", "Releasing connection") @@ -48,12 +82,80 @@ async def disconnect(): raise ConnectionError("Failed to disconnect from Prisma.") +# Transaction timeout constant (in milliseconds) +TRANSACTION_TIMEOUT = 15000 # 15 seconds - Increased from 5s to prevent timeout errors + + @asynccontextmanager -async def transaction(): - async with prisma.tx() as tx: +async def transaction(timeout: int = TRANSACTION_TIMEOUT): + """ + Create a database transaction with optional timeout. + + Args: + timeout: Transaction timeout in milliseconds. If None, uses TRANSACTION_TIMEOUT (15s). + """ + async with prisma.tx(timeout=timeout) as tx: yield tx +@asynccontextmanager +async def locked_transaction(key: str, timeout: int = TRANSACTION_TIMEOUT): + """ + Create a transaction and take a per-key advisory *transaction* lock. + + - Uses a 64-bit lock id via hashtextextended(key, 0) to avoid 32-bit collisions. + - Bound by lock_timeout and statement_timeout so it won't block indefinitely. + - Lock is held for the duration of the transaction and auto-released on commit/rollback. + + Args: + key: String lock key (e.g., "usr_trx_"). + timeout: Transaction/lock/statement timeout in milliseconds. + """ + async with transaction(timeout=timeout) as tx: + # Ensure we don't wait longer than desired + # Note: SET LOCAL doesn't support parameterized queries, must use string interpolation + await tx.execute_raw(f"SET LOCAL statement_timeout = '{int(timeout)}ms'") # type: ignore[arg-type] + await tx.execute_raw(f"SET LOCAL lock_timeout = '{int(timeout)}ms'") # type: ignore[arg-type] + + # Block until acquired or lock_timeout hits + try: + await tx.execute_raw( + "SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", + key, + ) + except Exception as e: + # Normalize PG's lock timeout error to TimeoutError for callers + if "lock timeout" in str(e).lower(): + raise TimeoutError( + f"Could not acquire lock for key={key!r} within {timeout}ms" + ) from e + raise + + yield tx + + +def get_database_schema() -> str: + """Extract database schema from DATABASE_URL.""" + parsed_url = urlparse(DATABASE_URL) + query_params = dict(parse_qsl(parsed_url.query)) + return query_params.get("schema", "public") + + +async def query_raw_with_schema(query_template: str, *args) -> list[dict]: + """Execute raw SQL query with proper schema handling.""" + schema = get_database_schema() + schema_prefix = f"{schema}." if schema != "public" else "" + formatted_query = query_template.format(schema_prefix=schema_prefix) + + import prisma as prisma_module + + result = await prisma_module.get_client().query_raw( + formatted_query, *args # type: ignore + ) + + return result + + class BaseDbModel(BaseModel): id: str = Field(default_factory=lambda: str(uuid4())) diff --git a/autogpt_platform/backend/backend/data/event_bus.py b/autogpt_platform/backend/backend/data/event_bus.py new file mode 100644 index 000000000000..48f9df87fae6 --- /dev/null +++ b/autogpt_platform/backend/backend/data/event_bus.py @@ -0,0 +1,158 @@ +import asyncio +import logging +from abc import ABC, abstractmethod +from typing import Any, AsyncGenerator, Generator, Generic, Optional, TypeVar + +from pydantic import BaseModel +from redis.asyncio.client import PubSub as AsyncPubSub +from redis.client import PubSub + +from backend.data import redis_client as redis +from backend.util import json +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) +config = Settings().config + + +M = TypeVar("M", bound=BaseModel) + + +class BaseRedisEventBus(Generic[M], ABC): + Model: type[M] + + @property + @abstractmethod + def event_bus_name(self) -> str: + pass + + @property + def Message(self) -> type["_EventPayloadWrapper[M]"]: + return _EventPayloadWrapper[self.Model] + + def _serialize_message(self, item: M, channel_key: str) -> tuple[str, str]: + MAX_MESSAGE_SIZE = config.max_message_size_limit + + try: + # Use backend.util.json.dumps which handles datetime and other complex types + message = json.dumps( + self.Message(payload=item), ensure_ascii=False, separators=(",", ":") + ) + except UnicodeError: + # Fallback to ASCII encoding if Unicode causes issues + message = json.dumps( + self.Message(payload=item), ensure_ascii=True, separators=(",", ":") + ) + logger.warning( + f"Unicode serialization failed, falling back to ASCII for channel {channel_key}" + ) + + # Check message size and truncate if necessary + message_size = len(message.encode("utf-8")) + if message_size > MAX_MESSAGE_SIZE: + logger.warning( + f"Message size {message_size} bytes exceeds limit {MAX_MESSAGE_SIZE} bytes for channel {channel_key}. " + "Truncating payload to prevent Redis connection issues." + ) + error_payload = { + "payload": { + "event_type": "error_comms_update", + "error": "Payload too large for Redis transmission", + "original_size_bytes": message_size, + "max_size_bytes": MAX_MESSAGE_SIZE, + } + } + message = json.dumps( + error_payload, ensure_ascii=False, separators=(",", ":") + ) + + channel_name = f"{self.event_bus_name}/{channel_key}" + logger.debug(f"[{channel_name}] Publishing an event to Redis {message}") + return message, channel_name + + def _deserialize_message(self, msg: Any, channel_key: str) -> M | None: + message_type = "pmessage" if "*" in channel_key else "message" + if msg["type"] != message_type: + return None + try: + logger.debug(f"[{channel_key}] Consuming an event from Redis {msg['data']}") + return self.Message.model_validate_json(msg["data"]).payload + except Exception as e: + logger.error(f"Failed to parse event result from Redis {msg} {e}") + + def _get_pubsub_channel( + self, connection: redis.Redis | redis.AsyncRedis, channel_key: str + ) -> tuple[PubSub | AsyncPubSub, str]: + full_channel_name = f"{self.event_bus_name}/{channel_key}" + pubsub = connection.pubsub() + return pubsub, full_channel_name + + +class _EventPayloadWrapper(BaseModel, Generic[M]): + """ + Wrapper model to allow `RedisEventBus.Model` to be a discriminated union + of multiple event types. + """ + + payload: M + + +class RedisEventBus(BaseRedisEventBus[M], ABC): + @property + def connection(self) -> redis.Redis: + return redis.get_redis() + + def publish_event(self, event: M, channel_key: str): + message, full_channel_name = self._serialize_message(event, channel_key) + self.connection.publish(full_channel_name, message) + + def listen_events(self, channel_key: str) -> Generator[M, None, None]: + pubsub, full_channel_name = self._get_pubsub_channel( + self.connection, channel_key + ) + assert isinstance(pubsub, PubSub) + + if "*" in channel_key: + pubsub.psubscribe(full_channel_name) + else: + pubsub.subscribe(full_channel_name) + + for message in pubsub.listen(): + if event := self._deserialize_message(message, channel_key): + yield event + + +class AsyncRedisEventBus(BaseRedisEventBus[M], ABC): + @property + async def connection(self) -> redis.AsyncRedis: + return await redis.get_redis_async() + + async def publish_event(self, event: M, channel_key: str): + message, full_channel_name = self._serialize_message(event, channel_key) + connection = await self.connection + await connection.publish(full_channel_name, message) + + async def listen_events(self, channel_key: str) -> AsyncGenerator[M, None]: + pubsub, full_channel_name = self._get_pubsub_channel( + await self.connection, channel_key + ) + assert isinstance(pubsub, AsyncPubSub) + + if "*" in channel_key: + await pubsub.psubscribe(full_channel_name) + else: + await pubsub.subscribe(full_channel_name) + + async for message in pubsub.listen(): + if event := self._deserialize_message(message, channel_key): + yield event + + async def wait_for_event( + self, channel_key: str, timeout: Optional[float] = None + ) -> M | None: + try: + return await asyncio.wait_for( + anext(aiter(self.listen_events(channel_key))), timeout + ) + except TimeoutError: + return None diff --git a/autogpt_platform/backend/backend/data/execution.py b/autogpt_platform/backend/backend/data/execution.py index 4775d94553df..2d9fc9c65b58 100644 --- a/autogpt_platform/backend/backend/data/execution.py +++ b/autogpt_platform/backend/backend/data/execution.py @@ -1,65 +1,310 @@ +import logging from collections import defaultdict -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone +from enum import Enum from multiprocessing import Manager -from typing import Any, AsyncGenerator, Generator, Generic, TypeVar +from queue import Empty +from typing import ( + Annotated, + Any, + AsyncGenerator, + Generator, + Generic, + Literal, + Optional, + TypeVar, + overload, +) from prisma.enums import AgentExecutionStatus from prisma.models import ( AgentGraphExecution, AgentNodeExecution, AgentNodeExecutionInputOutput, + AgentNodeExecutionKeyValueData, ) -from pydantic import BaseModel - -from backend.data.block import BlockData, BlockInput, CompletedBlockOutput -from backend.data.includes import EXECUTION_RESULT_INCLUDE, GRAPH_EXECUTION_INCLUDE -from backend.data.queue import AsyncRedisEventBus, RedisEventBus -from backend.util import json, mock +from prisma.types import ( + AgentGraphExecutionCreateInput, + AgentGraphExecutionUpdateManyMutationInput, + AgentGraphExecutionWhereInput, + AgentNodeExecutionCreateInput, + AgentNodeExecutionInputOutputCreateInput, + AgentNodeExecutionKeyValueDataCreateInput, + AgentNodeExecutionUpdateInput, + AgentNodeExecutionWhereInput, +) +from pydantic import BaseModel, ConfigDict, JsonValue, ValidationError +from pydantic.fields import Field + +from backend.server.v2.store.exceptions import DatabaseError +from backend.util import type as type_utils +from backend.util.json import SafeJson +from backend.util.models import Pagination +from backend.util.retry import func_retry from backend.util.settings import Config +from backend.util.truncate import truncate + +from .block import ( + BlockInput, + BlockType, + CompletedBlockOutput, + get_block, + get_io_block_ids, + get_webhook_block_ids, +) +from .db import BaseDbModel, query_raw_with_schema +from .event_bus import AsyncRedisEventBus, RedisEventBus +from .includes import ( + EXECUTION_RESULT_INCLUDE, + EXECUTION_RESULT_ORDER, + GRAPH_EXECUTION_INCLUDE_WITH_NODES, + graph_execution_include, +) +from .model import GraphExecutionStats, NodeExecutionStats +T = TypeVar("T") -class GraphExecutionEntry(BaseModel): - user_id: str - graph_exec_id: str - graph_id: str - start_node_execs: list["NodeExecutionEntry"] +logger = logging.getLogger(__name__) +config = Config() -class NodeExecutionEntry(BaseModel): +# -------------------------- Models -------------------------- # + + +class BlockErrorStats(BaseModel): + """Typed data structure for block error statistics.""" + + block_id: str + total_executions: int + failed_executions: int + + @property + def error_rate(self) -> float: + """Calculate error rate as a percentage.""" + if self.total_executions == 0: + return 0.0 + return (self.failed_executions / self.total_executions) * 100 + + +ExecutionStatus = AgentExecutionStatus + + +class GraphExecutionMeta(BaseDbModel): + id: str # type: ignore # Override base class to make this required user_id: str - graph_exec_id: str graph_id: str - node_exec_id: str - node_id: str - data: BlockInput + graph_version: int + preset_id: Optional[str] = None + status: ExecutionStatus + started_at: datetime + ended_at: datetime + class Stats(BaseModel): + model_config = ConfigDict( + extra="allow", + arbitrary_types_allowed=True, + ) -ExecutionStatus = AgentExecutionStatus + cost: int = Field( + default=0, + description="Execution cost (cents)", + ) + duration: float = Field( + default=0, + description="Seconds from start to end of run", + ) + duration_cpu_only: float = Field( + default=0, + description="CPU sec of duration", + ) + node_exec_time: float = Field( + default=0, + description="Seconds of total node runtime", + ) + node_exec_time_cpu_only: float = Field( + default=0, + description="CPU sec of node_exec_time", + ) + node_exec_count: int = Field( + default=0, + description="Number of node executions", + ) + node_error_count: int = Field( + default=0, + description="Number of node errors", + ) + error: str | None = Field( + default=None, + description="Error message if any", + ) + activity_status: str | None = Field( + default=None, + description="AI-generated summary of what the agent did", + ) -T = TypeVar("T") + def to_db(self) -> GraphExecutionStats: + return GraphExecutionStats( + cost=self.cost, + walltime=self.duration, + cputime=self.duration_cpu_only, + nodes_walltime=self.node_exec_time, + nodes_cputime=self.node_exec_time_cpu_only, + node_count=self.node_exec_count, + node_error_count=self.node_error_count, + error=self.error, + activity_status=self.activity_status, + ) + + stats: Stats | None + @staticmethod + def from_db(_graph_exec: AgentGraphExecution): + now = datetime.now(timezone.utc) + # TODO: make started_at and ended_at optional + start_time = _graph_exec.startedAt or _graph_exec.createdAt + end_time = _graph_exec.updatedAt or now + + try: + stats = GraphExecutionStats.model_validate(_graph_exec.stats) + except ValueError as e: + if _graph_exec.stats is not None: + logger.warning( + "Failed to parse invalid graph execution stats " + f"{_graph_exec.stats}: {e}" + ) + stats = None + + return GraphExecutionMeta( + id=_graph_exec.id, + user_id=_graph_exec.userId, + graph_id=_graph_exec.agentGraphId, + graph_version=_graph_exec.agentGraphVersion, + preset_id=_graph_exec.agentPresetId, + status=ExecutionStatus(_graph_exec.executionStatus), + started_at=start_time, + ended_at=end_time, + stats=( + GraphExecutionMeta.Stats( + cost=stats.cost, + duration=stats.walltime, + duration_cpu_only=stats.cputime, + node_exec_time=stats.nodes_walltime, + node_exec_time_cpu_only=stats.nodes_cputime, + node_exec_count=stats.node_count, + node_error_count=stats.node_error_count, + error=( + str(stats.error) + if isinstance(stats.error, Exception) + else stats.error + ), + activity_status=stats.activity_status, + ) + if stats + else None + ), + ) -class ExecutionQueue(Generic[T]): - """ - Queue for managing the execution of agents. - This will be shared between different processes - """ - def __init__(self): - self.queue = Manager().Queue() +class GraphExecution(GraphExecutionMeta): + inputs: BlockInput + outputs: CompletedBlockOutput - def add(self, execution: T) -> T: - self.queue.put(execution) - return execution + @staticmethod + def from_db(_graph_exec: AgentGraphExecution): + if _graph_exec.NodeExecutions is None: + raise ValueError("Node executions must be included in query") + + graph_exec = GraphExecutionMeta.from_db(_graph_exec) + + complete_node_executions = sorted( + [ + NodeExecutionResult.from_db(ne, _graph_exec.userId) + for ne in _graph_exec.NodeExecutions + if ne.executionStatus != ExecutionStatus.INCOMPLETE + ], + key=lambda ne: (ne.queue_time is None, ne.queue_time or ne.add_time), + ) - def get(self) -> T: - return self.queue.get() + inputs = { + **{ + # inputs from Agent Input Blocks + exec.input_data["name"]: exec.input_data.get("value") + for exec in complete_node_executions + if ( + (block := get_block(exec.block_id)) + and block.block_type == BlockType.INPUT + ) + }, + **{ + # input from webhook-triggered block + "payload": exec.input_data["payload"] + for exec in complete_node_executions + if ( + (block := get_block(exec.block_id)) + and block.block_type + in [BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL] + ) + }, + } - def empty(self) -> bool: - return self.queue.empty() + outputs: CompletedBlockOutput = defaultdict(list) + for exec in complete_node_executions: + if ( + block := get_block(exec.block_id) + ) and block.block_type == BlockType.OUTPUT: + outputs[exec.input_data["name"]].append( + exec.input_data.get("value", None) + ) + + return GraphExecution( + **{ + field_name: getattr(graph_exec, field_name) + for field_name in GraphExecutionMeta.model_fields + }, + inputs=inputs, + outputs=outputs, + ) + + +class GraphExecutionWithNodes(GraphExecution): + node_executions: list["NodeExecutionResult"] + + @staticmethod + def from_db(_graph_exec: AgentGraphExecution): + if _graph_exec.NodeExecutions is None: + raise ValueError("Node executions must be included in query") + + graph_exec_with_io = GraphExecution.from_db(_graph_exec) + + node_executions = sorted( + [ + NodeExecutionResult.from_db(ne, _graph_exec.userId) + for ne in _graph_exec.NodeExecutions + ], + key=lambda ne: (ne.queue_time is None, ne.queue_time or ne.add_time), + ) + return GraphExecutionWithNodes( + **{ + field_name: getattr(graph_exec_with_io, field_name) + for field_name in GraphExecution.model_fields + }, + node_executions=node_executions, + ) -class ExecutionResult(BaseModel): + def to_graph_execution_entry(self, user_context: "UserContext"): + return GraphExecutionEntry( + user_id=self.user_id, + graph_id=self.graph_id, + graph_version=self.graph_version or 0, + graph_exec_id=self.id, + nodes_input_masks={}, # FIXME: store credentials on AgentGraphExecution + user_context=user_context, + ) + + +class NodeExecutionResult(BaseModel): + user_id: str graph_id: str graph_version: int graph_exec_id: str @@ -75,102 +320,262 @@ class ExecutionResult(BaseModel): end_time: datetime | None @staticmethod - def from_graph(graph: AgentGraphExecution): - return ExecutionResult( - graph_id=graph.agentGraphId, - graph_version=graph.agentGraphVersion, - graph_exec_id=graph.id, - node_exec_id="", - node_id="", - block_id="", - status=graph.executionStatus, - # TODO: Populate input_data & output_data from AgentNodeExecutions - # Input & Output comes AgentInputBlock & AgentOutputBlock. - input_data={}, - output_data={}, - add_time=graph.createdAt, - queue_time=graph.createdAt, - start_time=graph.startedAt, - end_time=graph.updatedAt, - ) + def from_db(_node_exec: AgentNodeExecution, user_id: Optional[str] = None): + try: + stats = NodeExecutionStats.model_validate(_node_exec.stats or {}) + except (ValueError, ValidationError): + stats = NodeExecutionStats() - @staticmethod - def from_db(execution: AgentNodeExecution): - if execution.executionData: - # Execution that has been queued for execution will persist its data. - input_data = json.loads(execution.executionData, target_type=dict[str, Any]) + if stats.cleared_inputs: + input_data: BlockInput = defaultdict() + for name, messages in stats.cleared_inputs.items(): + input_data[name] = messages[-1] if messages else "" + elif _node_exec.executionData: + input_data = type_utils.convert(_node_exec.executionData, dict[str, Any]) else: - # For incomplete execution, executionData will not be yet available. input_data: BlockInput = defaultdict() - for data in execution.Input or []: - input_data[data.name] = json.loads(data.data) + for data in _node_exec.Input or []: + input_data[data.name] = type_utils.convert(data.data, type[Any]) output_data: CompletedBlockOutput = defaultdict(list) - for data in execution.Output or []: - output_data[data.name].append(json.loads(data.data)) - graph_execution: AgentGraphExecution | None = execution.AgentGraphExecution - - return ExecutionResult( + if stats.cleared_outputs: + for name, messages in stats.cleared_outputs.items(): + output_data[name].extend(messages) + else: + for data in _node_exec.Output or []: + output_data[data.name].append(type_utils.convert(data.data, type[Any])) + + graph_execution: AgentGraphExecution | None = _node_exec.GraphExecution + if graph_execution: + user_id = graph_execution.userId + elif not user_id: + raise ValueError( + "AgentGraphExecution must be included or user_id passed in" + ) + + return NodeExecutionResult( + user_id=user_id, graph_id=graph_execution.agentGraphId if graph_execution else "", graph_version=graph_execution.agentGraphVersion if graph_execution else 0, - graph_exec_id=execution.agentGraphExecutionId, - block_id=execution.AgentNode.agentBlockId if execution.AgentNode else "", - node_exec_id=execution.id, - node_id=execution.agentNodeId, - status=execution.executionStatus, + graph_exec_id=_node_exec.agentGraphExecutionId, + block_id=_node_exec.Node.agentBlockId if _node_exec.Node else "", + node_exec_id=_node_exec.id, + node_id=_node_exec.agentNodeId, + status=_node_exec.executionStatus, input_data=input_data, output_data=output_data, - add_time=execution.addedTime, - queue_time=execution.queuedTime, - start_time=execution.startedTime, - end_time=execution.endedTime, + add_time=_node_exec.addedTime, + queue_time=_node_exec.queuedTime, + start_time=_node_exec.startedTime, + end_time=_node_exec.endedTime, + ) + + def to_node_execution_entry( + self, user_context: "UserContext" + ) -> "NodeExecutionEntry": + return NodeExecutionEntry( + user_id=self.user_id, + graph_exec_id=self.graph_exec_id, + graph_id=self.graph_id, + node_exec_id=self.node_exec_id, + node_id=self.node_id, + block_id=self.block_id, + inputs=self.input_data, + user_context=user_context, ) # --------------------- Model functions --------------------- # +async def get_graph_executions( + graph_exec_id: Optional[str] = None, + graph_id: Optional[str] = None, + user_id: Optional[str] = None, + statuses: Optional[list[ExecutionStatus]] = None, + created_time_gte: Optional[datetime] = None, + created_time_lte: Optional[datetime] = None, + limit: Optional[int] = None, +) -> list[GraphExecutionMeta]: + """⚠️ **Optional `user_id` check**: MUST USE check in user-facing endpoints.""" + where_filter: AgentGraphExecutionWhereInput = { + "isDeleted": False, + } + if graph_exec_id: + where_filter["id"] = graph_exec_id + if user_id: + where_filter["userId"] = user_id + if graph_id: + where_filter["agentGraphId"] = graph_id + if created_time_gte or created_time_lte: + where_filter["createdAt"] = { + "gte": created_time_gte or datetime.min.replace(tzinfo=timezone.utc), + "lte": created_time_lte or datetime.max.replace(tzinfo=timezone.utc), + } + if statuses: + where_filter["OR"] = [{"executionStatus": status} for status in statuses] + + executions = await AgentGraphExecution.prisma().find_many( + where=where_filter, + order={"createdAt": "desc"}, + take=limit, + ) + return [GraphExecutionMeta.from_db(execution) for execution in executions] + + +class GraphExecutionsPaginated(BaseModel): + """Response schema for paginated graph executions.""" + + executions: list[GraphExecutionMeta] + pagination: Pagination + + +async def get_graph_executions_paginated( + user_id: str, + graph_id: Optional[str] = None, + page: int = 1, + page_size: int = 25, + statuses: Optional[list[ExecutionStatus]] = None, + created_time_gte: Optional[datetime] = None, + created_time_lte: Optional[datetime] = None, +) -> GraphExecutionsPaginated: + """Get paginated graph executions for a specific graph.""" + where_filter: AgentGraphExecutionWhereInput = { + "isDeleted": False, + "userId": user_id, + } + + if graph_id: + where_filter["agentGraphId"] = graph_id + if created_time_gte or created_time_lte: + where_filter["createdAt"] = { + "gte": created_time_gte or datetime.min.replace(tzinfo=timezone.utc), + "lte": created_time_lte or datetime.max.replace(tzinfo=timezone.utc), + } + if statuses: + where_filter["OR"] = [{"executionStatus": status} for status in statuses] + + total_count = await AgentGraphExecution.prisma().count(where=where_filter) + total_pages = (total_count + page_size - 1) // page_size + + offset = (page - 1) * page_size + executions = await AgentGraphExecution.prisma().find_many( + where=where_filter, + order={"createdAt": "desc"}, + take=page_size, + skip=offset, + ) + + return GraphExecutionsPaginated( + executions=[GraphExecutionMeta.from_db(execution) for execution in executions], + pagination=Pagination( + total_items=total_count, + total_pages=total_pages, + current_page=page, + page_size=page_size, + ), + ) + + +async def get_graph_execution_meta( + user_id: str, execution_id: str +) -> GraphExecutionMeta | None: + execution = await AgentGraphExecution.prisma().find_first( + where={"id": execution_id, "isDeleted": False, "userId": user_id} + ) + return GraphExecutionMeta.from_db(execution) if execution else None + + +@overload +async def get_graph_execution( + user_id: str, + execution_id: str, + include_node_executions: Literal[True], +) -> GraphExecutionWithNodes | None: ... + + +@overload +async def get_graph_execution( + user_id: str, + execution_id: str, + include_node_executions: Literal[False] = False, +) -> GraphExecution | None: ... + + +@overload +async def get_graph_execution( + user_id: str, + execution_id: str, + include_node_executions: bool = False, +) -> GraphExecution | GraphExecutionWithNodes | None: ... + + +async def get_graph_execution( + user_id: str, + execution_id: str, + include_node_executions: bool = False, +) -> GraphExecution | GraphExecutionWithNodes | None: + execution = await AgentGraphExecution.prisma().find_first( + where={"id": execution_id, "isDeleted": False, "userId": user_id}, + include=( + GRAPH_EXECUTION_INCLUDE_WITH_NODES + if include_node_executions + else graph_execution_include( + [*get_io_block_ids(), *get_webhook_block_ids()] + ) + ), + ) + if not execution: + return None + + return ( + GraphExecutionWithNodes.from_db(execution) + if include_node_executions + else GraphExecution.from_db(execution) + ) + + async def create_graph_execution( graph_id: str, graph_version: int, - nodes_input: list[tuple[str, BlockInput]], + starting_nodes_input: list[tuple[str, BlockInput]], user_id: str, -) -> tuple[str, list[ExecutionResult]]: + preset_id: str | None = None, +) -> GraphExecutionWithNodes: """ Create a new AgentGraphExecution record. Returns: The id of the AgentGraphExecution and the list of ExecutionResult for each node. """ result = await AgentGraphExecution.prisma().create( - data={ - "agentGraphId": graph_id, - "agentGraphVersion": graph_version, - "executionStatus": ExecutionStatus.QUEUED, - "AgentNodeExecutions": { - "create": [ # type: ignore - { - "agentNodeId": node_id, - "executionStatus": ExecutionStatus.INCOMPLETE, - "Input": { + data=AgentGraphExecutionCreateInput( + agentGraphId=graph_id, + agentGraphVersion=graph_version, + executionStatus=ExecutionStatus.QUEUED, + NodeExecutions={ + "create": [ + AgentNodeExecutionCreateInput( + agentNodeId=node_id, + executionStatus=ExecutionStatus.QUEUED, + queuedTime=datetime.now(tz=timezone.utc), + Input={ "create": [ - {"name": name, "data": json.dumps(data)} + {"name": name, "data": SafeJson(data)} for name, data in node_input.items() ] }, - } - for node_id, node_input in nodes_input + ) + for node_id, node_input in starting_nodes_input ] }, - "userId": user_id, - }, - include=GRAPH_EXECUTION_INCLUDE, + userId=user_id, + agentPresetId=preset_id, + ), + include=GRAPH_EXECUTION_INCLUDE_WITH_NODES, ) - return result.id, [ - ExecutionResult.from_db(execution) - for execution in result.AgentNodeExecutions or [] - ] + return GraphExecutionWithNodes.from_db(result) async def upsert_execution_input( @@ -192,33 +597,41 @@ async def upsert_execution_input( node_exec_id: [Optional] The id of the AgentNodeExecution that has no `input_name` as input. If not provided, it will find the eligible incomplete AgentNodeExecution or create a new one. Returns: - * The id of the created or existing AgentNodeExecution. - * Dict of node input data, key is the input name, value is the input data. + str: The id of the created or existing AgentNodeExecution. + dict[str, Any]: Node input data; key is the input name, value is the input data. """ - existing_execution = await AgentNodeExecution.prisma().find_first( - where={ # type: ignore - **({"id": node_exec_id} if node_exec_id else {}), - "agentNodeId": node_id, - "agentGraphExecutionId": graph_exec_id, - "executionStatus": ExecutionStatus.INCOMPLETE, - "Input": {"every": {"name": {"not": input_name}}}, + existing_exec_query_filter: AgentNodeExecutionWhereInput = { + "agentGraphExecutionId": graph_exec_id, + "agentNodeId": node_id, + "executionStatus": ExecutionStatus.INCOMPLETE, + "Input": { + "none": { + "name": input_name, + "time": {"gte": datetime.now(tz=timezone.utc) - timedelta(days=1)}, + } }, + } + if node_exec_id: + existing_exec_query_filter["id"] = node_exec_id + + existing_execution = await AgentNodeExecution.prisma().find_first( + where=existing_exec_query_filter, order={"addedTime": "asc"}, include={"Input": True}, ) - json_input_data = json.dumps(input_data) + json_input_data = SafeJson(input_data) if existing_execution: await AgentNodeExecutionInputOutput.prisma().create( - data={ - "name": input_name, - "data": json_input_data, - "referencedByInputExecId": existing_execution.id, - } + data=AgentNodeExecutionInputOutputCreateInput( + name=input_name, + data=json_input_data, + referencedByInputExecId=existing_execution.id, + ) ) return existing_execution.id, { **{ - input_data.name: json.loads(input_data.data) + input_data.name: type_utils.convert(input_data.data, type[Any]) for input_data in existing_execution.Input or [] }, input_name: input_data, @@ -226,12 +639,12 @@ async def upsert_execution_input( elif not node_exec_id: result = await AgentNodeExecution.prisma().create( - data={ - "agentNodeId": node_id, - "agentGraphExecutionId": graph_exec_id, - "executionStatus": ExecutionStatus.INCOMPLETE, - "Input": {"create": {"name": input_name, "data": json_input_data}}, - } + data=AgentNodeExecutionCreateInput( + agentNodeId=node_id, + agentGraphExecutionId=graph_exec_id, + executionStatus=ExecutionStatus.INCOMPLETE, + Input={"create": {"name": input_name, "data": json_input_data}}, + ) ) return result.id, {input_name: input_data} @@ -244,241 +657,473 @@ async def upsert_execution_input( async def upsert_execution_output( node_exec_id: str, output_name: str, - output_data: Any, + output_data: Any | None, ) -> None: """ Insert AgentNodeExecutionInputOutput record for as one of AgentNodeExecution.Output. """ - await AgentNodeExecutionInputOutput.prisma().create( - data={ - "name": output_name, - "data": json.dumps(output_data), - "referencedByOutputExecId": node_exec_id, - } - ) + data: AgentNodeExecutionInputOutputCreateInput = { + "name": output_name, + "referencedByOutputExecId": node_exec_id, + } + if output_data is not None: + data["data"] = SafeJson(output_data) + await AgentNodeExecutionInputOutput.prisma().create(data=data) -async def update_graph_execution_start_time(graph_exec_id: str): - await AgentGraphExecution.prisma().update( +async def update_graph_execution_start_time( + graph_exec_id: str, +) -> GraphExecution | None: + res = await AgentGraphExecution.prisma().update( where={"id": graph_exec_id}, data={ "executionStatus": ExecutionStatus.RUNNING, "startedAt": datetime.now(tz=timezone.utc), }, + include=graph_execution_include( + [*get_io_block_ids(), *get_webhook_block_ids()] + ), ) + return GraphExecution.from_db(res) if res else None async def update_graph_execution_stats( graph_exec_id: str, - stats: dict[str, Any], -) -> ExecutionResult: - status = ExecutionStatus.FAILED if stats.get("error") else ExecutionStatus.COMPLETED - res = await AgentGraphExecution.prisma().update( - where={"id": graph_exec_id}, - data={ - "executionStatus": status, - "stats": json.dumps(stats), + status: ExecutionStatus | None = None, + stats: GraphExecutionStats | None = None, +) -> GraphExecution | None: + update_data: AgentGraphExecutionUpdateManyMutationInput = {} + + if stats: + stats_dict = stats.model_dump() + if isinstance(stats_dict.get("error"), Exception): + stats_dict["error"] = str(stats_dict["error"]) + update_data["stats"] = SafeJson(stats_dict) + + if status: + update_data["executionStatus"] = status + + updated_count = await AgentGraphExecution.prisma().update_many( + where={ + "id": graph_exec_id, + "OR": [ + {"executionStatus": ExecutionStatus.RUNNING}, + {"executionStatus": ExecutionStatus.QUEUED}, + # Terminated graph can be resumed. + {"executionStatus": ExecutionStatus.TERMINATED}, + ], }, + data=update_data, ) - if not res: - raise ValueError(f"Execution {graph_exec_id} not found.") + if updated_count == 0: + return None - return ExecutionResult.from_graph(res) + graph_exec = await AgentGraphExecution.prisma().find_unique_or_raise( + where={"id": graph_exec_id}, + include=graph_execution_include( + [*get_io_block_ids(), *get_webhook_block_ids()] + ), + ) + return GraphExecution.from_db(graph_exec) -async def update_node_execution_stats(node_exec_id: str, stats: dict[str, Any]): - await AgentNodeExecution.prisma().update( - where={"id": node_exec_id}, - data={"stats": json.dumps(stats)}, +async def update_node_execution_status_batch( + node_exec_ids: list[str], + status: ExecutionStatus, + stats: dict[str, Any] | None = None, +): + await AgentNodeExecution.prisma().update_many( + where={"id": {"in": node_exec_ids}}, + data=_get_update_status_data(status, None, stats), ) -async def update_execution_status( +async def update_node_execution_status( node_exec_id: str, status: ExecutionStatus, execution_data: BlockInput | None = None, stats: dict[str, Any] | None = None, -) -> ExecutionResult: +) -> NodeExecutionResult: if status == ExecutionStatus.QUEUED and execution_data is None: raise ValueError("Execution data must be provided when queuing an execution.") - now = datetime.now(tz=timezone.utc) - data = { - **({"executionStatus": status}), - **({"queuedTime": now} if status == ExecutionStatus.QUEUED else {}), - **({"startedTime": now} if status == ExecutionStatus.RUNNING else {}), - **({"endedTime": now} if status == ExecutionStatus.FAILED else {}), - **({"endedTime": now} if status == ExecutionStatus.COMPLETED else {}), - **({"executionData": json.dumps(execution_data)} if execution_data else {}), - **({"stats": json.dumps(stats)} if stats else {}), - } - res = await AgentNodeExecution.prisma().update( where={"id": node_exec_id}, - data=data, # type: ignore + data=_get_update_status_data(status, execution_data, stats), include=EXECUTION_RESULT_INCLUDE, ) if not res: raise ValueError(f"Execution {node_exec_id} not found.") - return ExecutionResult.from_db(res) + return NodeExecutionResult.from_db(res) -async def get_execution_results(graph_exec_id: str) -> list[ExecutionResult]: - executions = await AgentNodeExecution.prisma().find_many( - where={"agentGraphExecutionId": graph_exec_id}, - include=EXECUTION_RESULT_INCLUDE, - order=[ - {"queuedTime": "asc"}, - {"addedTime": "asc"}, # Fallback: Incomplete execs has no queuedTime. - ], - ) - res = [ExecutionResult.from_db(execution) for execution in executions] - return res - +def _get_update_status_data( + status: ExecutionStatus, + execution_data: BlockInput | None = None, + stats: dict[str, Any] | None = None, +) -> AgentNodeExecutionUpdateInput: + now = datetime.now(tz=timezone.utc) + update_data: AgentNodeExecutionUpdateInput = {"executionStatus": status} -LIST_SPLIT = "_$_" -DICT_SPLIT = "_#_" -OBJC_SPLIT = "_@_" + if status == ExecutionStatus.QUEUED: + update_data["queuedTime"] = now + elif status == ExecutionStatus.RUNNING: + update_data["startedTime"] = now + elif status in (ExecutionStatus.FAILED, ExecutionStatus.COMPLETED): + update_data["endedTime"] = now + if execution_data: + update_data["executionData"] = SafeJson(execution_data) + if stats: + update_data["stats"] = SafeJson(stats) -def parse_execution_output(output: BlockData, name: str) -> Any | None: - # Allow extracting partial output data by name. - output_name, output_data = output + return update_data - if name == output_name: - return output_data - if name.startswith(f"{output_name}{LIST_SPLIT}"): - index = int(name.split(LIST_SPLIT)[1]) - if not isinstance(output_data, list) or len(output_data) <= index: - return None - return output_data[int(name.split(LIST_SPLIT)[1])] +async def delete_graph_execution( + graph_exec_id: str, user_id: str, soft_delete: bool = True +) -> None: + if soft_delete: + deleted_count = await AgentGraphExecution.prisma().update_many( + where={"id": graph_exec_id, "userId": user_id}, data={"isDeleted": True} + ) + else: + deleted_count = await AgentGraphExecution.prisma().delete_many( + where={"id": graph_exec_id, "userId": user_id} + ) + if deleted_count < 1: + raise DatabaseError( + f"Could not delete graph execution #{graph_exec_id}: not found" + ) - if name.startswith(f"{output_name}{DICT_SPLIT}"): - index = name.split(DICT_SPLIT)[1] - if not isinstance(output_data, dict) or index not in output_data: - return None - return output_data[index] - if name.startswith(f"{output_name}{OBJC_SPLIT}"): - index = name.split(OBJC_SPLIT)[1] - if isinstance(output_data, object) and hasattr(output_data, index): - return getattr(output_data, index) +async def get_node_execution(node_exec_id: str) -> NodeExecutionResult | None: + """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" + execution = await AgentNodeExecution.prisma().find_first( + where={"id": node_exec_id}, + include=EXECUTION_RESULT_INCLUDE, + ) + if not execution: return None + return NodeExecutionResult.from_db(execution) + + +async def get_node_executions( + graph_exec_id: str | None = None, + node_id: str | None = None, + block_ids: list[str] | None = None, + statuses: list[ExecutionStatus] | None = None, + limit: int | None = None, + created_time_gte: datetime | None = None, + created_time_lte: datetime | None = None, + include_exec_data: bool = True, +) -> list[NodeExecutionResult]: + """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" + where_clause: AgentNodeExecutionWhereInput = {} + if graph_exec_id: + where_clause["agentGraphExecutionId"] = graph_exec_id + if node_id: + where_clause["agentNodeId"] = node_id + if block_ids: + where_clause["Node"] = {"is": {"agentBlockId": {"in": block_ids}}} + if statuses: + where_clause["OR"] = [{"executionStatus": status} for status in statuses] + + if created_time_gte or created_time_lte: + where_clause["addedTime"] = { + "gte": created_time_gte or datetime.min.replace(tzinfo=timezone.utc), + "lte": created_time_lte or datetime.max.replace(tzinfo=timezone.utc), + } - return None - + executions = await AgentNodeExecution.prisma().find_many( + where=where_clause, + include=( + EXECUTION_RESULT_INCLUDE + if include_exec_data + else {"Node": True, "GraphExecution": True} + ), + order=EXECUTION_RESULT_ORDER, + take=limit, + ) + res = [NodeExecutionResult.from_db(execution) for execution in executions] + return res -def merge_execution_input(data: BlockInput) -> BlockInput: - """ - Merge all dynamic input pins which described by the following pattern: - - _$_ for list input. - - _#_ for dict input. - - _@_ for object input. - This function will construct pins with the same name into a single list/dict/object. - """ - # Merge all input with _$_ into a single list. - items = list(data.items()) - - for key, value in items: - if LIST_SPLIT not in key: - continue - name, index = key.split(LIST_SPLIT) - if not index.isdigit(): - raise ValueError(f"Invalid key: {key}, #{index} index must be an integer.") - - data[name] = data.get(name, []) - if int(index) >= len(data[name]): - # Pad list with empty string on missing indices. - data[name].extend([""] * (int(index) - len(data[name]) + 1)) - data[name][int(index)] = value - - # Merge all input with _#_ into a single dict. - for key, value in items: - if DICT_SPLIT not in key: - continue - name, index = key.split(DICT_SPLIT) - data[name] = data.get(name, {}) - data[name][index] = value - - # Merge all input with _@_ into a single object. - for key, value in items: - if OBJC_SPLIT not in key: - continue - name, index = key.split(OBJC_SPLIT) - if name not in data or not isinstance(data[name], object): - data[name] = mock.MockObject() - setattr(data[name], index, value) - - return data - - -async def get_latest_execution(node_id: str, graph_eid: str) -> ExecutionResult | None: +async def get_latest_node_execution( + node_id: str, graph_eid: str +) -> NodeExecutionResult | None: + """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" execution = await AgentNodeExecution.prisma().find_first( where={ - "agentNodeId": node_id, "agentGraphExecutionId": graph_eid, - "executionStatus": {"not": ExecutionStatus.INCOMPLETE}, - "executionData": {"not": None}, # type: ignore + "agentNodeId": node_id, + "OR": [ + {"executionStatus": ExecutionStatus.QUEUED}, + {"executionStatus": ExecutionStatus.RUNNING}, + {"executionStatus": ExecutionStatus.COMPLETED}, + {"executionStatus": ExecutionStatus.TERMINATED}, + {"executionStatus": ExecutionStatus.FAILED}, + ], }, - order={"queuedTime": "desc"}, include=EXECUTION_RESULT_INCLUDE, + order=EXECUTION_RESULT_ORDER, ) if not execution: return None - return ExecutionResult.from_db(execution) + return NodeExecutionResult.from_db(execution) -async def get_incomplete_executions( - node_id: str, graph_eid: str -) -> list[ExecutionResult]: - executions = await AgentNodeExecution.prisma().find_many( - where={ - "agentNodeId": node_id, - "agentGraphExecutionId": graph_eid, - "executionStatus": ExecutionStatus.INCOMPLETE, - }, - include=EXECUTION_RESULT_INCLUDE, - ) - return [ExecutionResult.from_db(execution) for execution in executions] +# ----------------- Execution Infrastructure ----------------- # + + +class UserContext(BaseModel): + """Generic user context for graph execution containing user-specific settings.""" + + timezone: str + + +class GraphExecutionEntry(BaseModel): + user_id: str + graph_exec_id: str + graph_id: str + graph_version: int + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None + user_context: UserContext + + +class NodeExecutionEntry(BaseModel): + user_id: str + graph_exec_id: str + graph_id: str + node_exec_id: str + node_id: str + block_id: str + inputs: BlockInput + user_context: UserContext + + +class ExecutionQueue(Generic[T]): + """ + Queue for managing the execution of agents. + This will be shared between different processes + """ + + def __init__(self): + self.queue = Manager().Queue() + + def add(self, execution: T) -> T: + self.queue.put(execution) + return execution + + def get(self) -> T: + return self.queue.get() + + def empty(self) -> bool: + return self.queue.empty() + + def get_or_none(self) -> T | None: + try: + return self.queue.get_nowait() + except Empty: + return None # --------------------- Event Bus --------------------- # -config = Config() +class ExecutionEventType(str, Enum): + GRAPH_EXEC_UPDATE = "graph_execution_update" + NODE_EXEC_UPDATE = "node_execution_update" + ERROR_COMMS_UPDATE = "error_comms_update" -class RedisExecutionEventBus(RedisEventBus[ExecutionResult]): - Model = ExecutionResult + +class GraphExecutionEvent(GraphExecution): + event_type: Literal[ExecutionEventType.GRAPH_EXEC_UPDATE] = ( + ExecutionEventType.GRAPH_EXEC_UPDATE + ) + + +class NodeExecutionEvent(NodeExecutionResult): + event_type: Literal[ExecutionEventType.NODE_EXEC_UPDATE] = ( + ExecutionEventType.NODE_EXEC_UPDATE + ) + + +ExecutionEvent = Annotated[ + GraphExecutionEvent | NodeExecutionEvent, Field(discriminator="event_type") +] + + +class RedisExecutionEventBus(RedisEventBus[ExecutionEvent]): + Model = ExecutionEvent # type: ignore @property def event_bus_name(self) -> str: return config.execution_event_bus_name - def publish(self, res: ExecutionResult): - self.publish_event(res, f"{res.graph_id}/{res.graph_exec_id}") + def publish(self, res: GraphExecution | NodeExecutionResult): + if isinstance(res, GraphExecution): + self._publish_graph_exec_update(res) + else: + self._publish_node_exec_update(res) + + def _publish_node_exec_update(self, res: NodeExecutionResult): + event = NodeExecutionEvent.model_validate(res.model_dump()) + self._publish(event, f"{res.user_id}/{res.graph_id}/{res.graph_exec_id}") + + def _publish_graph_exec_update(self, res: GraphExecution): + event = GraphExecutionEvent.model_validate(res.model_dump()) + self._publish(event, f"{res.user_id}/{res.graph_id}/{res.id}") + + def _publish(self, event: ExecutionEvent, channel: str): + """ + truncate inputs and outputs to avoid large payloads + """ + limit = config.max_message_size_limit // 2 + if isinstance(event, GraphExecutionEvent): + event.inputs = truncate(event.inputs, limit) + event.outputs = truncate(event.outputs, limit) + elif isinstance(event, NodeExecutionEvent): + event.input_data = truncate(event.input_data, limit) + event.output_data = truncate(event.output_data, limit) + + super().publish_event(event, channel) def listen( - self, graph_id: str = "*", graph_exec_id: str = "*" - ) -> Generator[ExecutionResult, None, None]: - for execution_result in self.listen_events(f"{graph_id}/{graph_exec_id}"): - yield execution_result + self, user_id: str, graph_id: str = "*", graph_exec_id: str = "*" + ) -> Generator[ExecutionEvent, None, None]: + for event in self.listen_events(f"{user_id}/{graph_id}/{graph_exec_id}"): + yield event -class AsyncRedisExecutionEventBus(AsyncRedisEventBus[ExecutionResult]): - Model = ExecutionResult +class AsyncRedisExecutionEventBus(AsyncRedisEventBus[ExecutionEvent]): + Model = ExecutionEvent # type: ignore @property def event_bus_name(self) -> str: return config.execution_event_bus_name - async def publish(self, res: ExecutionResult): - await self.publish_event(res, f"{res.graph_id}/{res.graph_exec_id}") + @func_retry + async def publish(self, res: GraphExecutionMeta | NodeExecutionResult): + if isinstance(res, GraphExecutionMeta): + await self._publish_graph_exec_update(res) + else: + await self._publish_node_exec_update(res) + + async def _publish_node_exec_update(self, res: NodeExecutionResult): + event = NodeExecutionEvent.model_validate(res.model_dump()) + await self._publish(event, f"{res.user_id}/{res.graph_id}/{res.graph_exec_id}") + + async def _publish_graph_exec_update(self, res: GraphExecutionMeta): + # GraphExecutionEvent requires inputs and outputs fields that GraphExecutionMeta doesn't have + # Add default empty values for compatibility + event_data = res.model_dump() + event_data.setdefault("inputs", {}) + event_data.setdefault("outputs", {}) + event = GraphExecutionEvent.model_validate(event_data) + await self._publish(event, f"{res.user_id}/{res.graph_id}/{res.id}") + + async def _publish(self, event: ExecutionEvent, channel: str): + """ + truncate inputs and outputs to avoid large payloads + """ + limit = config.max_message_size_limit // 2 + if isinstance(event, GraphExecutionEvent): + event.inputs = truncate(event.inputs, limit) + event.outputs = truncate(event.outputs, limit) + elif isinstance(event, NodeExecutionEvent): + event.input_data = truncate(event.input_data, limit) + event.output_data = truncate(event.output_data, limit) + + await super().publish_event(event, channel) async def listen( - self, graph_id: str = "*", graph_exec_id: str = "*" - ) -> AsyncGenerator[ExecutionResult, None]: - async for execution_result in self.listen_events(f"{graph_id}/{graph_exec_id}"): - yield execution_result + self, user_id: str, graph_id: str = "*", graph_exec_id: str = "*" + ) -> AsyncGenerator[ExecutionEvent, None]: + async for event in self.listen_events(f"{user_id}/{graph_id}/{graph_exec_id}"): + yield event + + +# --------------------- KV Data Functions --------------------- # + + +async def get_execution_kv_data(user_id: str, key: str) -> Any | None: + """ + Get key-value data for a user and key. + + Args: + user_id: The id of the User. + key: The key to retrieve data for. + + Returns: + The data associated with the key, or None if not found. + """ + kv_data = await AgentNodeExecutionKeyValueData.prisma().find_unique( + where={"userId_key": {"userId": user_id, "key": key}} + ) + return ( + type_utils.convert(kv_data.data, type[Any]) + if kv_data and kv_data.data + else None + ) + + +async def set_execution_kv_data( + user_id: str, node_exec_id: str, key: str, data: Any +) -> Any | None: + """ + Set key-value data for a user and key. + + Args: + user_id: The id of the User. + node_exec_id: The id of the AgentNodeExecution. + key: The key to store data under. + data: The data to store. + """ + resp = await AgentNodeExecutionKeyValueData.prisma().upsert( + where={"userId_key": {"userId": user_id, "key": key}}, + data={ + "create": AgentNodeExecutionKeyValueDataCreateInput( + userId=user_id, + agentNodeExecutionId=node_exec_id, + key=key, + data=SafeJson(data) if data is not None else None, + ), + "update": { + "agentNodeExecutionId": node_exec_id, + "data": SafeJson(data) if data is not None else None, + }, + }, + ) + return type_utils.convert(resp.data, type[Any]) if resp and resp.data else None + + +async def get_block_error_stats( + start_time: datetime, end_time: datetime +) -> list[BlockErrorStats]: + """Get block execution stats using efficient SQL aggregation.""" + + query_template = """ + SELECT + n."agentBlockId" as block_id, + COUNT(*) as total_executions, + SUM(CASE WHEN ne."executionStatus" = 'FAILED' THEN 1 ELSE 0 END) as failed_executions + FROM {schema_prefix}"AgentNodeExecution" ne + JOIN {schema_prefix}"AgentNode" n ON ne."agentNodeId" = n.id + WHERE ne."addedTime" >= $1::timestamp AND ne."addedTime" <= $2::timestamp + GROUP BY n."agentBlockId" + HAVING COUNT(*) >= 10 + """ + + result = await query_raw_with_schema(query_template, start_time, end_time) + + # Convert to typed data structures + return [ + BlockErrorStats( + block_id=row["block_id"], + total_executions=int(row["total_executions"]), + failed_executions=int(row["failed_executions"]), + ) + for row in result + ] diff --git a/autogpt_platform/backend/backend/data/generate_data.py b/autogpt_platform/backend/backend/data/generate_data.py new file mode 100644 index 000000000000..edaa2772c8f0 --- /dev/null +++ b/autogpt_platform/backend/backend/data/generate_data.py @@ -0,0 +1,109 @@ +import logging +from collections import defaultdict +from datetime import datetime + +from prisma.enums import AgentExecutionStatus + +from backend.data.execution import get_graph_executions +from backend.data.graph import get_graph_metadata +from backend.data.model import UserExecutionSummaryStats +from backend.server.v2.store.exceptions import DatabaseError +from backend.util.logging import TruncatedLogger + +logger = TruncatedLogger(logging.getLogger(__name__), prefix="[SummaryData]") + + +async def get_user_execution_summary_data( + user_id: str, start_time: datetime, end_time: datetime +) -> UserExecutionSummaryStats: + """Gather all summary data for a user in a time range. + + This function fetches graph executions once and aggregates all required + statistics in a single pass for efficiency. + """ + try: + # Fetch graph executions once + executions = await get_graph_executions( + user_id=user_id, + created_time_gte=start_time, + created_time_lte=end_time, + ) + + # Initialize aggregation variables + total_credits_used = 0.0 + total_executions = len(executions) + successful_runs = 0 + failed_runs = 0 + terminated_runs = 0 + execution_times = [] + agent_usage = defaultdict(int) + cost_by_graph_id = defaultdict(float) + + # Single pass through executions to aggregate all stats + for execution in executions: + # Count execution statuses (including TERMINATED as failed) + if execution.status == AgentExecutionStatus.COMPLETED: + successful_runs += 1 + elif execution.status == AgentExecutionStatus.FAILED: + failed_runs += 1 + elif execution.status == AgentExecutionStatus.TERMINATED: + terminated_runs += 1 + + # Aggregate costs from stats + if execution.stats and hasattr(execution.stats, "cost"): + cost_in_dollars = execution.stats.cost / 100 + total_credits_used += cost_in_dollars + cost_by_graph_id[execution.graph_id] += cost_in_dollars + + # Collect execution times + if execution.stats and hasattr(execution.stats, "duration"): + execution_times.append(execution.stats.duration) + + # Count agent usage + agent_usage[execution.graph_id] += 1 + + # Calculate derived stats + total_execution_time = sum(execution_times) + average_execution_time = ( + total_execution_time / len(execution_times) if execution_times else 0 + ) + + # Find most used agent + most_used_agent = "No agents used" + if agent_usage: + most_used_agent_id = max(agent_usage, key=lambda k: agent_usage[k]) + try: + graph_meta = await get_graph_metadata(graph_id=most_used_agent_id) + most_used_agent = ( + graph_meta.name if graph_meta else f"Agent {most_used_agent_id[:8]}" + ) + except Exception: + logger.warning(f"Could not get metadata for graph {most_used_agent_id}") + most_used_agent = f"Agent {most_used_agent_id[:8]}" + + # Convert graph_ids to agent names for cost breakdown + cost_breakdown = {} + for graph_id, cost in cost_by_graph_id.items(): + try: + graph_meta = await get_graph_metadata(graph_id=graph_id) + agent_name = graph_meta.name if graph_meta else f"Agent {graph_id[:8]}" + except Exception: + logger.warning(f"Could not get metadata for graph {graph_id}") + agent_name = f"Agent {graph_id[:8]}" + cost_breakdown[agent_name] = cost + + # Build the summary stats object (include terminated runs as failed) + return UserExecutionSummaryStats( + total_credits_used=total_credits_used, + total_executions=total_executions, + successful_runs=successful_runs, + failed_runs=failed_runs + terminated_runs, + most_used_agent=most_used_agent, + total_execution_time=total_execution_time, + average_execution_time=average_execution_time, + cost_breakdown=cost_breakdown, + ) + + except Exception as e: + logger.error(f"Failed to get user summary data: {e}") + raise DatabaseError(f"Failed to get user summary data: {e}") from e diff --git a/autogpt_platform/backend/backend/data/graph.py b/autogpt_platform/backend/backend/data/graph.py index 1a8a021d45f0..1e423feb5e45 100644 --- a/autogpt_platform/backend/backend/data/graph.py +++ b/autogpt_platform/backend/backend/data/graph.py @@ -1,24 +1,40 @@ -import asyncio import logging import uuid from collections import defaultdict -from datetime import datetime, timezone -from typing import Any, Literal, Optional, Type - -import prisma -from prisma.models import AgentGraph, AgentGraphExecution, AgentNode, AgentNodeLink -from prisma.types import AgentGraphWhereInput +from typing import TYPE_CHECKING, Any, Literal, Optional, cast + +from prisma.enums import SubmissionStatus +from prisma.models import AgentGraph, AgentNode, AgentNodeLink, StoreListingVersion +from prisma.types import ( + AgentGraphCreateInput, + AgentGraphWhereInput, + AgentNodeCreateInput, + AgentNodeLinkCreateInput, + StoreListingVersionWhereInput, +) +from pydantic import Field, JsonValue, create_model from pydantic.fields import computed_field from backend.blocks.agent import AgentExecutorBlock -from backend.blocks.basic import AgentInputBlock, AgentOutputBlock -from backend.util import json - -from .block import BlockInput, BlockType, get_block, get_blocks -from .db import BaseDbModel, transaction -from .execution import ExecutionStatus +from backend.blocks.io import AgentInputBlock, AgentOutputBlock +from backend.blocks.llm import LlmModel +from backend.data.db import prisma as db +from backend.data.model import ( + CredentialsField, + CredentialsFieldInfo, + CredentialsMetaInput, + is_credentials_field_name, +) +from backend.integrations.providers import ProviderName +from backend.util import type as type_utils +from backend.util.json import SafeJson + +from .block import Block, BlockInput, BlockSchema, BlockType, get_block, get_blocks +from .db import BaseDbModel, query_raw_with_schema, transaction from .includes import AGENT_GRAPH_INCLUDE, AGENT_NODE_INCLUDE -from .integrations import Webhook + +if TYPE_CHECKING: + from .integrations import Webhook logger = logging.getLogger(__name__) @@ -52,24 +68,32 @@ class Node(BaseDbModel): input_links: list[Link] = [] output_links: list[Link] = [] - webhook_id: Optional[str] = None + @property + def block(self) -> Block[BlockSchema, BlockSchema]: + block = get_block(self.block_id) + if not block: + raise ValueError( + f"Block #{self.block_id} does not exist -> Node #{self.id} is invalid" + ) + return block class NodeModel(Node): graph_id: str graph_version: int - webhook: Optional[Webhook] = None + webhook_id: Optional[str] = None + webhook: Optional["Webhook"] = None @staticmethod - def from_db(node: AgentNode): - if not node.AgentBlock: - raise ValueError(f"Invalid node {node.id}, invalid AgentBlock.") + def from_db(node: AgentNode, for_export: bool = False) -> "NodeModel": + from .integrations import Webhook + obj = NodeModel( id=node.id, - block_id=node.AgentBlock.id, - input_default=json.loads(node.constantInput, target_type=dict[str, Any]), - metadata=json.loads(node.metadata, target_type=dict[str, Any]), + block_id=node.agentBlockId, + input_default=type_utils.convert(node.constantInput, dict[str, Any]), + metadata=type_utils.convert(node.metadata, dict[str, Any]), graph_id=node.agentGraphId, graph_version=node.agentGraphVersion, webhook_id=node.webhookId, @@ -77,144 +101,252 @@ def from_db(node: AgentNode): ) obj.input_links = [Link.from_db(link) for link in node.Input or []] obj.output_links = [Link.from_db(link) for link in node.Output or []] + if for_export: + return obj.stripped_for_export() return obj def is_triggered_by_event_type(self, event_type: str) -> bool: - if not (block := get_block(self.block_id)): - raise ValueError(f"Block #{self.block_id} not found for node #{self.id}") - if not block.webhook_config: - raise TypeError("This method can't be used on non-webhook blocks") - if not block.webhook_config.event_filter_input: - return True - event_filter = self.input_default.get(block.webhook_config.event_filter_input) - if not event_filter: - raise ValueError(f"Event filter is not configured on node #{self.id}") - return event_type in [ - block.webhook_config.event_format.format(event=k) - for k in event_filter - if event_filter[k] is True - ] + return self.block.is_triggered_by_event_type(self.input_default, event_type) + def stripped_for_export(self) -> "NodeModel": + """ + Returns a copy of the node model, stripped of any non-transferable properties + """ + stripped_node = self.model_copy(deep=True) + # Remove credentials from node input + if stripped_node.input_default: + stripped_node.input_default = NodeModel._filter_secrets_from_node_input( + stripped_node.input_default, self.block.input_schema.jsonschema() + ) -# Fix 2-way reference Node <-> Webhook -Webhook.model_rebuild() + if ( + stripped_node.block.block_type == BlockType.INPUT + and "value" in stripped_node.input_default + ): + stripped_node.input_default["value"] = "" + # Remove webhook info + stripped_node.webhook_id = None + stripped_node.webhook = None -class GraphExecution(BaseDbModel): - execution_id: str - started_at: datetime - ended_at: datetime - duration: float - total_run_time: float - status: ExecutionStatus - graph_id: str - graph_version: int + return stripped_node @staticmethod - def from_db(execution: AgentGraphExecution): - now = datetime.now(timezone.utc) - start_time = execution.startedAt or execution.createdAt - end_time = execution.updatedAt or now - duration = (end_time - start_time).total_seconds() - total_run_time = duration - - try: - stats = json.loads(execution.stats or "{}", target_type=dict[str, Any]) - except ValueError: - stats = {} - - duration = stats.get("walltime", duration) - total_run_time = stats.get("nodes_walltime", total_run_time) - - return GraphExecution( - id=execution.id, - execution_id=execution.id, - started_at=start_time, - ended_at=end_time, - duration=duration, - total_run_time=total_run_time, - status=ExecutionStatus(execution.executionStatus), - graph_id=execution.agentGraphId, - graph_version=execution.agentGraphVersion, - ) + def _filter_secrets_from_node_input( + input_data: dict[str, Any], schema: dict[str, Any] | None + ) -> dict[str, Any]: + sensitive_keys = ["credentials", "api_key", "password", "token", "secret"] + field_schemas = schema.get("properties", {}) if schema else {} + result = {} + for key, value in input_data.items(): + field_schema: dict | None = field_schemas.get(key) + if (field_schema and field_schema.get("secret", False)) or any( + sensitive_key in key.lower() for sensitive_key in sensitive_keys + ): + # This is a secret value -> filter this key-value pair out + continue + elif isinstance(value, dict): + result[key] = NodeModel._filter_secrets_from_node_input( + value, field_schema + ) + else: + result[key] = value + return result -class Graph(BaseDbModel): +class BaseGraph(BaseDbModel): version: int = 1 is_active: bool = True - is_template: bool = False name: str description: str nodes: list[Node] = [] links: list[Link] = [] + forked_from_id: str | None = None + forked_from_version: int | None = None @computed_field @property def input_schema(self) -> dict[str, Any]: return self._generate_schema( - AgentInputBlock.Input, - [ - node.input_default + *( + (block.input_schema, node.input_default) for node in self.nodes - if (b := get_block(node.block_id)) - and b.block_type == BlockType.INPUT - and "name" in node.input_default - ], + if (block := node.block).block_type == BlockType.INPUT + and issubclass(block.input_schema, AgentInputBlock.Input) + ) ) @computed_field @property def output_schema(self) -> dict[str, Any]: return self._generate_schema( - AgentOutputBlock.Input, - [ - node.input_default + *( + (block.input_schema, node.input_default) for node in self.nodes - if (b := get_block(node.block_id)) - and b.block_type == BlockType.OUTPUT - and "name" in node.input_default - ], + if (block := node.block).block_type == BlockType.OUTPUT + and issubclass(block.input_schema, AgentOutputBlock.Input) + ) + ) + + @computed_field + @property + def has_external_trigger(self) -> bool: + return self.webhook_input_node is not None + + @property + def webhook_input_node(self) -> Node | None: + return next( + ( + node + for node in self.nodes + if node.block.block_type + in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL) + ), + None, ) @staticmethod def _generate_schema( - type_class: Type[AgentInputBlock.Input] | Type[AgentOutputBlock.Input], - data: list[dict], + *props: tuple[type[AgentInputBlock.Input] | type[AgentOutputBlock.Input], dict], ) -> dict[str, Any]: - props = [] - for p in data: + schema_fields: list[AgentInputBlock.Input | AgentOutputBlock.Input] = [] + for type_class, input_default in props: try: - props.append(type_class(**p)) + schema_fields.append(type_class.model_construct(**input_default)) except Exception as e: - logger.warning(f"Invalid {type_class}: {p}, {e}") + logger.error(f"Invalid {type_class}: {input_default}, {e}") return { "type": "object", "properties": { p.name: { + **{ + k: v + for k, v in p.generate_schema().items() + if k not in ["description", "default"] + }, "secret": p.secret, - "advanced": p.advanced, + # Default value has to be set for advanced fields. + "advanced": p.advanced and p.value is not None, "title": p.title or p.name, **({"description": p.description} if p.description else {}), **({"default": p.value} if p.value is not None else {}), } - for p in props + for p in schema_fields }, - "required": [p.name for p in props if p.value is None], + "required": [p.name for p in schema_fields if p.value is None], + } + + +class Graph(BaseGraph): + sub_graphs: list[BaseGraph] = [] # Flattened sub-graphs + + @computed_field + @property + def credentials_input_schema(self) -> dict[str, Any]: + return self._credentials_input_schema.jsonschema() + + @property + def _credentials_input_schema(self) -> type[BlockSchema]: + graph_credentials_inputs = self.aggregate_credentials_inputs() + logger.debug( + f"Combined credentials input fields for graph #{self.id} ({self.name}): " + f"{graph_credentials_inputs}" + ) + + # Warn if same-provider credentials inputs can't be combined (= bad UX) + graph_cred_fields = list(graph_credentials_inputs.values()) + for i, (field, keys) in enumerate(graph_cred_fields): + for other_field, other_keys in list(graph_cred_fields)[i + 1 :]: + if field.provider != other_field.provider: + continue + if ProviderName.HTTP in field.provider: + continue + + # If this happens, that means a block implementation probably needs + # to be updated. + logger.warning( + "Multiple combined credentials fields " + f"for provider {field.provider} " + f"on graph #{self.id} ({self.name}); " + f"fields: {field} <> {other_field};" + f"keys: {keys} <> {other_keys}." + ) + + fields: dict[str, tuple[type[CredentialsMetaInput], CredentialsMetaInput]] = { + agg_field_key: ( + CredentialsMetaInput[ + Literal[tuple(field_info.provider)], # type: ignore + Literal[tuple(field_info.supported_types)], # type: ignore + ], + CredentialsField( + required_scopes=set(field_info.required_scopes or []), + discriminator=field_info.discriminator, + discriminator_mapping=field_info.discriminator_mapping, + discriminator_values=field_info.discriminator_values, + ), + ) + for agg_field_key, (field_info, _) in graph_credentials_inputs.items() } + return create_model( + self.name.replace(" ", "") + "CredentialsInputSchema", + __base__=BlockSchema, + **fields, # type: ignore + ) + + def aggregate_credentials_inputs( + self, + ) -> dict[str, tuple[CredentialsFieldInfo, set[tuple[str, str]]]]: + """ + Returns: + dict[aggregated_field_key, tuple( + CredentialsFieldInfo: A spec for one aggregated credentials field + (now includes discriminator_values from matching nodes) + set[(node_id, field_name)]: Node credentials fields that are + compatible with this aggregated field spec + )] + """ + # First collect all credential field data with input defaults + node_credential_data = [] + + for graph in [self] + self.sub_graphs: + for node in graph.nodes: + for ( + field_name, + field_info, + ) in node.block.input_schema.get_credentials_fields_info().items(): + + discriminator = field_info.discriminator + if not discriminator: + node_credential_data.append((field_info, (node.id, field_name))) + continue + + discriminator_value = node.input_default.get(discriminator) + if discriminator_value is None: + node_credential_data.append((field_info, (node.id, field_name))) + continue + + discriminated_info = field_info.discriminate(discriminator_value) + discriminated_info.discriminator_values.add(discriminator_value) + + node_credential_data.append( + (discriminated_info, (node.id, field_name)) + ) + + # Combine credential field info (this will merge discriminator_values automatically) + return CredentialsFieldInfo.combine(*node_credential_data) + class GraphModel(Graph): user_id: str nodes: list[NodeModel] = [] # type: ignore @property - def starting_nodes(self) -> list[Node]: + def starting_nodes(self) -> list[NodeModel]: outbound_nodes = {link.sink_id for link in self.links} input_nodes = { - v.id - for v in self.nodes - if (b := get_block(v.block_id)) and b.block_type == BlockType.INPUT + node.id for node in self.nodes if node.block.block_type == BlockType.INPUT } return [ node @@ -222,212 +354,353 @@ def starting_nodes(self) -> list[Node]: if node.id not in outbound_nodes or node.id in input_nodes ] + def meta(self) -> "GraphMeta": + """ + Returns a GraphMeta object with metadata about the graph. + This is used to return metadata about the graph without exposing nodes and links. + """ + return GraphMeta.from_graph(self) + def reassign_ids(self, user_id: str, reassign_graph_id: bool = False): """ Reassigns all IDs in the graph to new UUIDs. This method can be used before storing a new graph to the database. """ + if reassign_graph_id: + graph_id_map = { + self.id: str(uuid.uuid4()), + **{sub_graph.id: str(uuid.uuid4()) for sub_graph in self.sub_graphs}, + } + else: + graph_id_map = {} + self._reassign_ids(self, user_id, graph_id_map) + for sub_graph in self.sub_graphs: + self._reassign_ids(sub_graph, user_id, graph_id_map) + + @staticmethod + def _reassign_ids( + graph: BaseGraph, + user_id: str, + graph_id_map: dict[str, str], + ): # Reassign Graph ID - id_map = {node.id: str(uuid.uuid4()) for node in self.nodes} - if reassign_graph_id: - self.id = str(uuid.uuid4()) + if graph.id in graph_id_map: + graph.id = graph_id_map[graph.id] # Reassign Node IDs - for node in self.nodes: + id_map = {node.id: str(uuid.uuid4()) for node in graph.nodes} + for node in graph.nodes: node.id = id_map[node.id] # Reassign Link IDs - for link in self.links: - link.source_id = id_map[link.source_id] - link.sink_id = id_map[link.sink_id] + for link in graph.links: + if link.source_id in id_map: + link.source_id = id_map[link.source_id] + if link.sink_id in id_map: + link.sink_id = id_map[link.sink_id] # Reassign User IDs for agent blocks - for node in self.nodes: + for node in graph.nodes: if node.block_id != AgentExecutorBlock().id: continue node.input_default["user_id"] = user_id - node.input_default.setdefault("data", {}) + node.input_default.setdefault("inputs", {}) + if ( + graph_id := node.input_default.get("graph_id") + ) and graph_id in graph_id_map: + node.input_default["graph_id"] = graph_id_map[graph_id] + + def validate_graph( + self, + for_run: bool = False, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, + ): + """ + Validate graph structure and raise `ValueError` on issues. + For structured error reporting, use `validate_graph_get_errors`. + """ + self._validate_graph(self, for_run, nodes_input_masks) + for sub_graph in self.sub_graphs: + self._validate_graph(sub_graph, for_run, nodes_input_masks) + + @staticmethod + def _validate_graph( + graph: BaseGraph, + for_run: bool = False, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, + ) -> None: + errors = GraphModel._validate_graph_get_errors( + graph, for_run, nodes_input_masks + ) + if errors: + # Just raise the first error for backward compatibility + first_error = next(iter(errors.values())) + first_field_error = next(iter(first_error.values())) + raise ValueError(first_field_error) + + def validate_graph_get_errors( + self, + for_run: bool = False, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, + ) -> dict[str, dict[str, str]]: + """ + Validate graph and return structured errors per node. + + Returns: dict[node_id, dict[field_name, error_message]] + """ + return { + **self._validate_graph_get_errors(self, for_run, nodes_input_masks), + **{ + node_id: error + for sub_graph in self.sub_graphs + for node_id, error in self._validate_graph_get_errors( + sub_graph, for_run, nodes_input_masks + ).items() + }, + } + + @staticmethod + def _validate_graph_get_errors( + graph: BaseGraph, + for_run: bool = False, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, + ) -> dict[str, dict[str, str]]: + """ + Validate graph and return structured errors per node. - self.validate_graph() + Returns: dict[node_id, dict[field_name, error_message]] + """ + # First, check for structural issues with the graph + try: + GraphModel._validate_graph_structure(graph) + except ValueError: + # If structural validation fails, we can't provide per-node errors + # so we re-raise as is + raise + + # Collect errors per node + node_errors: dict[str, dict[str, str]] = defaultdict(dict) + + # Validate smart decision maker nodes + nodes_block = { + node.id: block + for node in graph.nodes + if (block := get_block(node.block_id)) is not None + } - def validate_graph(self, for_run: bool = False): - def sanitize(name): - return name.split("_#_")[0].split("_@_")[0].split("_$_")[0] + input_links: dict[str, list[Link]] = defaultdict(list) - input_links = defaultdict(list) - for link in self.links: + for link in graph.links: input_links[link.sink_id].append(link) # Nodes: required fields are filled or connected and dependencies are satisfied - for node in self.nodes: - block = get_block(node.block_id) - if block is None: + for node in graph.nodes: + if (block := nodes_block.get(node.id)) is None: + # For invalid blocks, we still raise immediately as this is a structural issue raise ValueError(f"Invalid block {node.block_id} for node #{node.id}") + node_input_mask = ( + nodes_input_masks.get(node.id, {}) if nodes_input_masks else {} + ) provided_inputs = set( - [sanitize(name) for name in node.input_default] - + [sanitize(link.sink_name) for link in input_links.get(node.id, [])] + [_sanitize_pin_name(name) for name in node.input_default] + + [ + _sanitize_pin_name(link.sink_name) + for link in input_links.get(node.id, []) + ] + + ([name for name in node_input_mask] if node_input_mask else []) ) - for name in block.input_schema.get_required_fields(): + InputSchema = block.input_schema + + for name in (required_fields := InputSchema.get_required_fields()): if ( name not in provided_inputs - and not ( - name == "payload" - and block.block_type - in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL) - ) + # Checking availability of credentials is done by ExecutionManager + and name not in InputSchema.get_credentials_fields() + # Validate only I/O nodes, or validate everything when executing and ( - for_run # Skip input completion validation, unless when executing. - or block.block_type == BlockType.INPUT - or block.block_type == BlockType.OUTPUT - or block.block_type == BlockType.AGENT + for_run + or block.block_type + in [ + BlockType.INPUT, + BlockType.OUTPUT, + BlockType.AGENT, + ] ) ): - raise ValueError( - f"Node {block.name} #{node.id} required input missing: `{name}`" + node_errors[node.id][name] = "This field is required" + + if ( + block.block_type == BlockType.INPUT + and (input_key := node.input_default.get("name")) + and is_credentials_field_name(input_key) + ): + node_errors[node.id]["name"] = ( + f"'{input_key}' is a reserved input name: " + "'credentials' and `*_credentials` are reserved" ) # Get input schema properties and check dependencies - input_schema = block.input_schema.model_fields - required_fields = block.input_schema.get_required_fields() + input_fields = InputSchema.model_fields - def has_value(name): + def has_value(node: Node, name: str): return ( - node is not None - and name in node.input_default - and node.input_default[name] is not None - and str(node.input_default[name]).strip() != "" - ) or (name in input_schema and input_schema[name].default is not None) + ( + name in node.input_default + and node.input_default[name] is not None + and str(node.input_default[name]).strip() != "" + ) + or (name in input_fields and input_fields[name].default is not None) + or ( + name in node_input_mask + and node_input_mask[name] is not None + and str(node_input_mask[name]).strip() != "" + ) + ) # Validate dependencies between fields - for field_name, field_info in input_schema.items(): - # Apply input dependency validation only on run & field with depends_on - json_schema_extra = field_info.json_schema_extra or {} - dependencies = json_schema_extra.get("depends_on", []) - if not for_run or not dependencies: + for field_name in input_fields.keys(): + field_json_schema = InputSchema.get_field_schema(field_name) + + dependencies: list[str] = [] + + # Check regular field dependencies (only pre graph execution) + if for_run: + dependencies.extend(field_json_schema.get("depends_on", [])) + + # Require presence of credentials discriminator (always). + # The `discriminator` is either the name of a sibling field (str), + # or an object that discriminates between possible types for this field: + # {"propertyName": prop_name, "mapping": {prop_value: sub_schema}} + if ( + discriminator := field_json_schema.get("discriminator") + ) and isinstance(discriminator, str): + dependencies.append(discriminator) + + if not dependencies: continue # Check if dependent field has value in input_default - field_has_value = has_value(field_name) + field_has_value = has_value(node, field_name) field_is_required = field_name in required_fields # Check for missing dependencies when dependent field is present - missing_deps = [dep for dep in dependencies if not has_value(dep)] + missing_deps = [dep for dep in dependencies if not has_value(node, dep)] if missing_deps and (field_has_value or field_is_required): - raise ValueError( - f"Node {block.name} #{node.id}: Field `{field_name}` requires [{', '.join(missing_deps)}] to be set" - ) + node_errors[node.id][ + field_name + ] = f"Requires {', '.join(missing_deps)} to be set" - node_map = {v.id: v for v in self.nodes} + return node_errors + + @staticmethod + def _validate_graph_structure(graph: BaseGraph): + """Validate graph structure (links, connections, etc.)""" + node_map = {v.id: v for v in graph.nodes} def is_static_output_block(nid: str) -> bool: - bid = node_map[nid].block_id - b = get_block(bid) - return b.static_output if b else False + return node_map[nid].block.static_output # Links: links are connected and the connected pin data type are compatible. - for link in self.links: + for link in graph.links: source = (link.source_id, link.source_name) sink = (link.sink_id, link.sink_name) - suffix = f"Link {source} <-> {sink}" + prefix = f"Link {source} <-> {sink}" for i, (node_id, name) in enumerate([source, sink]): node = node_map.get(node_id) if not node: raise ValueError( - f"{suffix}, {node_id} is invalid node id, available nodes: {node_map.keys()}" + f"{prefix}, {node_id} is invalid node id, available nodes: {node_map.keys()}" ) block = get_block(node.block_id) if not block: blocks = {v().id: v().name for v in get_blocks().values()} raise ValueError( - f"{suffix}, {node.block_id} is invalid block id, available blocks: {blocks}" + f"{prefix}, {node.block_id} is invalid block id, available blocks: {blocks}" ) - sanitized_name = sanitize(name) + sanitized_name = _sanitize_pin_name(name) vals = node.input_default if i == 0: fields = ( block.output_schema.get_fields() - if block.block_type != BlockType.AGENT + if block.block_type not in [BlockType.AGENT] else vals.get("output_schema", {}).get("properties", {}).keys() ) else: fields = ( block.input_schema.get_fields() - if block.block_type != BlockType.AGENT + if block.block_type not in [BlockType.AGENT] else vals.get("input_schema", {}).get("properties", {}).keys() ) - if sanitized_name not in fields: + if sanitized_name not in fields and not _is_tool_pin(name): fields_msg = f"Allowed fields: {fields}" - raise ValueError(f"{suffix}, `{name}` invalid, {fields_msg}") + raise ValueError(f"{prefix}, `{name}` invalid, {fields_msg}") if is_static_output_block(link.source_id): link.is_static = True # Each value block output should be static. @staticmethod - def from_db(graph: AgentGraph, for_export: bool = False): + def from_db( + graph: AgentGraph, + for_export: bool = False, + sub_graphs: list[AgentGraph] | None = None, + ) -> "GraphModel": return GraphModel( id=graph.id, - user_id=graph.userId, + user_id=graph.userId if not for_export else "", version=graph.version, + forked_from_id=graph.forkedFromId, + forked_from_version=graph.forkedFromVersion, is_active=graph.isActive, - is_template=graph.isTemplate, name=graph.name or "", description=graph.description or "", - nodes=[ - NodeModel.from_db(GraphModel._process_node(node, for_export)) - for node in graph.AgentNodes or [] - ], + nodes=[NodeModel.from_db(node, for_export) for node in graph.Nodes or []], links=list( { Link.from_db(link) - for node in graph.AgentNodes or [] + for node in graph.Nodes or [] for link in (node.Input or []) + (node.Output or []) } ), + sub_graphs=[ + GraphModel.from_db(sub_graph, for_export) + for sub_graph in sub_graphs or [] + ], ) - @staticmethod - def _process_node(node: AgentNode, for_export: bool) -> AgentNode: - if for_export: - # Remove credentials from node input - if node.constantInput: - constant_input = json.loads( - node.constantInput, target_type=dict[str, Any] - ) - constant_input = GraphModel._hide_node_input_credentials(constant_input) - node.constantInput = json.dumps(constant_input) - # Remove webhook info - node.webhookId = None - node.Webhook = None +def _is_tool_pin(name: str) -> bool: + return name.startswith("tools_^_") + + +def _sanitize_pin_name(name: str) -> str: + sanitized_name = name.split("_#_")[0].split("_@_")[0].split("_$_")[0] + if _is_tool_pin(sanitized_name): + return "tools" + return sanitized_name + + +class GraphMeta(Graph): + user_id: str - return node + # Easy work-around to prevent exposing nodes and links in the API response + nodes: list[NodeModel] = Field(default=[], exclude=True) # type: ignore + links: list[Link] = Field(default=[], exclude=True) @staticmethod - def _hide_node_input_credentials(input_data: dict[str, Any]) -> dict[str, Any]: - sensitive_keys = ["credentials", "api_key", "password", "token", "secret"] - result = {} - for key, value in input_data.items(): - if isinstance(value, dict): - result[key] = GraphModel._hide_node_input_credentials(value) - elif isinstance(value, str) and any( - sensitive_key in key.lower() for sensitive_key in sensitive_keys - ): - # Skip this key-value pair in the result - continue - else: - result[key] = value - return result + def from_graph(graph: GraphModel) -> "GraphMeta": + return GraphMeta(**graph.model_dump()) # --------------------- CRUD functions --------------------- # async def get_node(node_id: str) -> NodeModel: + """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" node = await AgentNode.prisma().find_unique_or_raise( where={"id": node_id}, include=AGENT_NODE_INCLUDE, @@ -436,6 +709,7 @@ async def get_node(node_id: str) -> NodeModel: async def set_node_webhook(node_id: str, webhook_id: str | None) -> NodeModel: + """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" node = await AgentNode.prisma().update( where={"id": node_id}, data=( @@ -450,27 +724,25 @@ async def set_node_webhook(node_id: str, webhook_id: str | None) -> NodeModel: return NodeModel.from_db(node) -async def get_graphs( +async def list_graphs( user_id: str, - filter_by: Literal["active", "template"] | None = "active", -) -> list[GraphModel]: + filter_by: Literal["active"] | None = "active", +) -> list[GraphMeta]: """ Retrieves graph metadata objects. Default behaviour is to get all currently active graphs. Args: - filter_by: An optional filter to either select templates or active graphs. + filter_by: An optional filter to either select graphs. user_id: The ID of the user that owns the graph. Returns: - list[GraphModel]: A list of objects representing the retrieved graphs. + list[GraphMeta]: A list of objects representing the retrieved graphs. """ where_clause: AgentGraphWhereInput = {"userId": user_id} if filter_by == "active": where_clause["isActive"] = True - elif filter_by == "template": - where_clause["isTemplate"] = True graphs = await AgentGraph.prisma().find_many( where=where_clause, @@ -479,10 +751,13 @@ async def get_graphs( include=AGENT_GRAPH_INCLUDE, ) - graph_models = [] + graph_models: list[GraphMeta] = [] for graph in graphs: try: - graph_models.append(GraphModel.from_db(graph)) + graph_meta = GraphModel.from_db(graph).meta() + # Trigger serialization to validate that the graph is well formed + graph_meta.model_dump() + graph_models.append(graph_meta) except Exception as e: logger.error(f"Error processing graph {graph.id}: {e}") continue @@ -490,53 +765,196 @@ async def get_graphs( return graph_models -async def get_executions(user_id: str) -> list[GraphExecution]: - executions = await AgentGraphExecution.prisma().find_many( - where={"userId": user_id}, - order={"createdAt": "desc"}, +async def get_graph_metadata(graph_id: str, version: int | None = None) -> Graph | None: + where_clause: AgentGraphWhereInput = { + "id": graph_id, + } + + if version is not None: + where_clause["version"] = version + + graph = await AgentGraph.prisma().find_first( + where=where_clause, + order={"version": "desc"}, ) - return [GraphExecution.from_db(execution) for execution in executions] + if not graph: + return None -async def get_execution(user_id: str, execution_id: str) -> GraphExecution | None: - execution = await AgentGraphExecution.prisma().find_first( - where={"id": execution_id, "userId": user_id} + return Graph( + id=graph.id, + name=graph.name or "", + description=graph.description or "", + version=graph.version, + is_active=graph.isActive, ) - return GraphExecution.from_db(execution) if execution else None async def get_graph( graph_id: str, version: int | None = None, - template: bool = False, user_id: str | None = None, for_export: bool = False, + include_subgraphs: bool = False, ) -> GraphModel | None: """ Retrieves a graph from the DB. - Defaults to the version with `is_active` if `version` is not passed, - or the latest version with `is_template` if `template=True`. + Defaults to the version with `is_active` if `version` is not passed. Returns `None` if the record is not found. """ where_clause: AgentGraphWhereInput = { "id": graph_id, - "isTemplate": template, } + if version is not None: where_clause["version"] = version - elif not template: - where_clause["isActive"] = True - if user_id is not None and not template: - where_clause["userId"] = user_id + graph = await AgentGraph.prisma().find_first( + where=where_clause, + include=AGENT_GRAPH_INCLUDE, + order={"version": "desc"}, + ) + if graph is None: + return None + + if graph.userId != user_id: + store_listing_filter: StoreListingVersionWhereInput = { + "agentGraphId": graph_id, + "isDeleted": False, + "submissionStatus": SubmissionStatus.APPROVED, + } + if version is not None: + store_listing_filter["agentGraphVersion"] = version + + # For access, the graph must be owned by the user or listed in the store + if not await StoreListingVersion.prisma().find_first( + where=store_listing_filter, order={"agentGraphVersion": "desc"} + ): + return None + + if include_subgraphs or for_export: + sub_graphs = await get_sub_graphs(graph) + return GraphModel.from_db( + graph=graph, + sub_graphs=sub_graphs, + for_export=for_export, + ) + + return GraphModel.from_db(graph, for_export) + + +async def get_graph_as_admin( + graph_id: str, + version: int | None = None, + user_id: str | None = None, + for_export: bool = False, +) -> GraphModel | None: + """ + Intentionally parallels the get_graph but should only be used for admin tasks, because can return any graph that's been submitted + Retrieves a graph from the DB. + Defaults to the version with `is_active` if `version` is not passed. + + Returns `None` if the record is not found. + """ + logger.warning(f"Getting {graph_id=} {version=} as ADMIN {user_id=} {for_export=}") + where_clause: AgentGraphWhereInput = { + "id": graph_id, + } + + if version is not None: + where_clause["version"] = version graph = await AgentGraph.prisma().find_first( where=where_clause, include=AGENT_GRAPH_INCLUDE, order={"version": "desc"}, ) - return GraphModel.from_db(graph, for_export) if graph else None + + # For access, the graph must be owned by the user or listed in the store + if graph is None or ( + graph.userId != user_id + and not ( + await StoreListingVersion.prisma().find_first( + where={ + "agentGraphId": graph_id, + "agentGraphVersion": version or graph.version, + } + ) + ) + ): + return None + + if for_export: + sub_graphs = await get_sub_graphs(graph) + return GraphModel.from_db( + graph=graph, + sub_graphs=sub_graphs, + for_export=for_export, + ) + + return GraphModel.from_db(graph, for_export) + + +async def get_sub_graphs(graph: AgentGraph) -> list[AgentGraph]: + """ + Iteratively fetches all sub-graphs of a given graph, and flattens them into a list. + This call involves a DB fetch in batch, breadth-first, per-level of graph depth. + On each DB fetch we will only fetch the sub-graphs that are not already in the list. + """ + sub_graphs = {graph.id: graph} + search_graphs = [graph] + agent_block_id = AgentExecutorBlock().id + + while search_graphs: + sub_graph_ids = [ + (graph_id, graph_version) + for graph in search_graphs + for node in graph.Nodes or [] + if ( + node.AgentBlock + and node.AgentBlock.id == agent_block_id + and (graph_id := cast(str, dict(node.constantInput).get("graph_id"))) + and ( + graph_version := cast( + int, dict(node.constantInput).get("graph_version") + ) + ) + ) + ] + if not sub_graph_ids: + break + + graphs = await AgentGraph.prisma().find_many( + where={ + "OR": [ + { + "id": graph_id, + "version": graph_version, + "userId": graph.userId, # Ensure the sub-graph is owned by the same user + } + for graph_id, graph_version in sub_graph_ids + ] + }, + include=AGENT_GRAPH_INCLUDE, + ) + + search_graphs = [graph for graph in graphs if graph.id not in sub_graphs] + sub_graphs.update({graph.id: graph for graph in search_graphs}) + + return [g for g in sub_graphs.values() if g.id != graph.id] + + +async def get_connected_output_nodes(node_id: str) -> list[tuple[Link, Node]]: + links = await AgentNodeLink.prisma().find_many( + where={"agentNodeSourceId": node_id}, + include={"AgentNodeSink": {"include": AGENT_NODE_INCLUDE}}, + ) + return [ + (Link.from_db(link), NodeModel.from_db(link.AgentNodeSink)) + for link in links + if link.AgentNodeSink + ] async def set_graph_active_version(graph_id: str, version: int, user_id: str) -> None: @@ -590,55 +1008,78 @@ async def create_graph(graph: Graph, user_id: str) -> GraphModel: async with transaction() as tx: await __create_graph(tx, graph, user_id) - if created_graph := await get_graph( - graph.id, graph.version, graph.is_template, user_id=user_id - ): + if created_graph := await get_graph(graph.id, graph.version, user_id=user_id): return created_graph raise ValueError(f"Created graph {graph.id} v{graph.version} is not in DB") +async def fork_graph(graph_id: str, graph_version: int, user_id: str) -> GraphModel: + """ + Forks a graph by copying it and all its nodes and links to a new graph. + """ + graph = await get_graph(graph_id, graph_version, user_id, True) + if not graph: + raise ValueError(f"Graph {graph_id} v{graph_version} not found") + + # Set forked from ID and version as itself as it's about ot be copied + graph.forked_from_id = graph.id + graph.forked_from_version = graph.version + graph.name = f"{graph.name} (copy)" + graph.reassign_ids(user_id=user_id, reassign_graph_id=True) + graph.validate_graph(for_run=False) + + async with transaction() as tx: + await __create_graph(tx, graph, user_id) + + return graph + + async def __create_graph(tx, graph: Graph, user_id: str): - await AgentGraph.prisma(tx).create( - data={ - "id": graph.id, - "version": graph.version, - "name": graph.name, - "description": graph.description, - "isTemplate": graph.is_template, - "isActive": graph.is_active, - "userId": user_id, - } + graphs = [graph] + graph.sub_graphs + + await AgentGraph.prisma(tx).create_many( + data=[ + AgentGraphCreateInput( + id=graph.id, + version=graph.version, + name=graph.name, + description=graph.description, + isActive=graph.is_active, + userId=user_id, + forkedFromId=graph.forked_from_id, + forkedFromVersion=graph.forked_from_version, + ) + for graph in graphs + ] ) - await asyncio.gather( - *[ - AgentNode.prisma(tx).create( - { - "id": node.id, - "agentBlockId": node.block_id, - "agentGraphId": graph.id, - "agentGraphVersion": graph.version, - "constantInput": json.dumps(node.input_default), - "metadata": json.dumps(node.metadata), - } + await AgentNode.prisma(tx).create_many( + data=[ + AgentNodeCreateInput( + id=node.id, + agentGraphId=graph.id, + agentGraphVersion=graph.version, + agentBlockId=node.block_id, + constantInput=SafeJson(node.input_default), + metadata=SafeJson(node.metadata), ) + for graph in graphs for node in graph.nodes ] ) - await asyncio.gather( - *[ - AgentNodeLink.prisma(tx).create( - { - "id": str(uuid.uuid4()), - "sourceName": link.source_name, - "sinkName": link.sink_name, - "agentNodeSourceId": link.source_id, - "agentNodeSinkId": link.sink_id, - "isStatic": link.is_static, - } + await AgentNodeLink.prisma(tx).create_many( + data=[ + AgentNodeLinkCreateInput( + id=str(uuid.uuid4()), + sourceName=link.source_name, + sinkName=link.sink_name, + agentNodeSourceId=link.source_id, + agentNodeSinkId=link.sink_id, + isStatic=link.is_static, ) + for graph in graphs for link in graph.links ] ) @@ -681,19 +1122,23 @@ async def fix_llm_provider_credentials(): store = IntegrationCredentialsStore() - broken_nodes = await prisma.get_client().query_raw( - """ - SELECT graph."userId" user_id, + broken_nodes = [] + try: + broken_nodes = await query_raw_with_schema( + """ + SELECT graph."userId" user_id, node.id node_id, node."constantInput" node_preset_input - FROM platform."AgentNode" node - LEFT JOIN platform."AgentGraph" graph - ON node."agentGraphId" = graph.id - WHERE node."constantInput"::jsonb->'credentials'->>'provider' = 'llm' - ORDER BY graph."userId"; - """ - ) - logger.info(f"Fixing LLM credential inputs on {len(broken_nodes)} nodes") + FROM {schema_prefix}"AgentNode" node + LEFT JOIN {schema_prefix}"AgentGraph" graph + ON node."agentGraphId" = graph.id + WHERE node."constantInput"::jsonb->'credentials'->>'provider' = 'llm' + ORDER BY graph."userId"; + """ + ) + logger.info(f"Fixing LLM credential inputs on {len(broken_nodes)} nodes") + except Exception as e: + logger.error(f"Error fixing LLM credential inputs: {e}") user_id: str = "" user_integrations = None @@ -706,7 +1151,7 @@ async def fix_llm_provider_credentials(): raise RuntimeError(f"Impossible state while processing node {node}") node_id: str = node["node_id"] - node_preset_input: dict = json.loads(node["node_preset_input"]) + node_preset_input: dict = node["node_preset_input"] credentials_meta: dict = node_preset_input["credentials"] credentials = next( @@ -739,8 +1184,52 @@ async def fix_llm_provider_credentials(): ) continue - store.update_creds(user_id, credentials) + await store.update_creds(user_id, credentials) await AgentNode.prisma().update( where={"id": node_id}, - data={"constantInput": json.dumps(node_preset_input)}, + data={"constantInput": SafeJson(node_preset_input)}, + ) + + +async def migrate_llm_models(migrate_to: LlmModel): + """ + Update all LLM models in all AI blocks that don't exist in the enum. + Note: Only updates top level LlmModel SchemaFields of blocks (won't update nested fields). + """ + logger.info("Migrating LLM models") + # Scan all blocks and search for LlmModel fields + llm_model_fields: dict[str, str] = {} # {block_id: field_name} + + # Search for all LlmModel fields + for block_type in get_blocks().values(): + block = block_type() + from pydantic.fields import FieldInfo + + fields: dict[str, FieldInfo] = block.input_schema.model_fields + + # Collect top-level LlmModel fields + for field_name, field in fields.items(): + if field.annotation == LlmModel: + llm_model_fields[block.id] = field_name + + # Convert enum values to a list of strings for the SQL query + enum_values = [v.value for v in LlmModel] + escaped_enum_values = repr(tuple(enum_values)) # hack but works + + # Update each block + for id, path in llm_model_fields.items(): + query = f""" + UPDATE platform."AgentNode" + SET "constantInput" = jsonb_set("constantInput", $1, to_jsonb($2), true) + WHERE "agentBlockId" = $3 + AND "constantInput" ? ($4)::text + AND "constantInput"->>($4)::text NOT IN {escaped_enum_values} + """ + + await db.execute_raw( + query, # type: ignore - is supposed to be LiteralString + [path], + migrate_to.value, + id, + path, ) diff --git a/autogpt_platform/backend/backend/data/graph_test.py b/autogpt_platform/backend/backend/data/graph_test.py new file mode 100644 index 000000000000..18be29f67a44 --- /dev/null +++ b/autogpt_platform/backend/backend/data/graph_test.py @@ -0,0 +1,332 @@ +import json +from typing import Any +from uuid import UUID + +import autogpt_libs.auth.models +import fastapi.exceptions +import pytest +from pytest_snapshot.plugin import Snapshot + +import backend.server.v2.store.model as store +from backend.blocks.basic import StoreValueBlock +from backend.blocks.io import AgentInputBlock, AgentOutputBlock +from backend.data.block import BlockSchema +from backend.data.graph import Graph, Link, Node +from backend.data.model import SchemaField +from backend.data.user import DEFAULT_USER_ID +from backend.server.model import CreateGraph +from backend.usecases.sample import create_test_user +from backend.util.test import SpinTestServer + + +@pytest.mark.asyncio(loop_scope="session") +async def test_graph_creation(server: SpinTestServer, snapshot: Snapshot): + """ + Test the creation of a graph with nodes and links. + + This test ensures that: + 1. A graph can be successfully created with valid connections. + 2. The created graph has the correct structure and properties. + + Args: + server (SpinTestServer): The test server instance. + """ + value_block = StoreValueBlock().id + input_block = AgentInputBlock().id + + graph = Graph( + id="test_graph", + name="TestGraph", + description="Test graph", + nodes=[ + Node(id="node_1", block_id=value_block), + Node(id="node_2", block_id=input_block, input_default={"name": "input"}), + Node(id="node_3", block_id=value_block), + ], + links=[ + Link( + source_id="node_1", + sink_id="node_2", + source_name="output", + sink_name="name", + ), + ], + ) + create_graph = CreateGraph(graph=graph) + created_graph = await server.agent_server.test_create_graph( + create_graph, DEFAULT_USER_ID + ) + + assert UUID(created_graph.id) + assert created_graph.name == "TestGraph" + + assert len(created_graph.nodes) == 3 + assert UUID(created_graph.nodes[0].id) + assert UUID(created_graph.nodes[1].id) + assert UUID(created_graph.nodes[2].id) + + nodes = created_graph.nodes + links = created_graph.links + assert len(links) == 1 + assert links[0].source_id != links[0].sink_id + assert links[0].source_id in {nodes[0].id, nodes[1].id, nodes[2].id} + assert links[0].sink_id in {nodes[0].id, nodes[1].id, nodes[2].id} + + # Create a serializable version of the graph for snapshot testing + # Remove dynamic IDs to make snapshots reproducible + graph_data = { + "name": created_graph.name, + "description": created_graph.description, + "nodes_count": len(created_graph.nodes), + "links_count": len(created_graph.links), + "node_blocks": [node.block_id for node in created_graph.nodes], + "link_structure": [ + {"source_name": link.source_name, "sink_name": link.sink_name} + for link in created_graph.links + ], + } + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(graph_data, indent=2, sort_keys=True), "grph_struct" + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_get_input_schema(server: SpinTestServer, snapshot: Snapshot): + """ + Test the get_input_schema method of a created graph. + + This test ensures that: + 1. A graph can be created with a single node. + 2. The input schema of the created graph is correctly generated. + 3. The input schema contains the expected input name and node id. + + Args: + server (SpinTestServer): The test server instance. + """ + value_block = StoreValueBlock().id + input_block = AgentInputBlock().id + output_block = AgentOutputBlock().id + + graph = Graph( + name="TestInputSchema", + description="Test input schema", + nodes=[ + Node( + id="node_0_a", + block_id=input_block, + input_default={ + "name": "in_key_a", + "title": "Key A", + "value": "A", + "advanced": True, + }, + metadata={"id": "node_0_a"}, + ), + Node( + id="node_0_b", + block_id=input_block, + input_default={"name": "in_key_b", "advanced": True}, + metadata={"id": "node_0_b"}, + ), + Node(id="node_1", block_id=value_block, metadata={"id": "node_1"}), + Node( + id="node_2", + block_id=output_block, + input_default={ + "name": "out_key", + "description": "This is an output key", + }, + metadata={"id": "node_2"}, + ), + ], + links=[ + Link( + source_id="node_0_a", + sink_id="node_1", + source_name="result", + sink_name="input", + ), + Link( + source_id="node_0_b", + sink_id="node_1", + source_name="result", + sink_name="input", + ), + Link( + source_id="node_1", + sink_id="node_2", + source_name="output", + sink_name="value", + ), + ], + ) + + create_graph = CreateGraph(graph=graph) + created_graph = await server.agent_server.test_create_graph( + create_graph, DEFAULT_USER_ID + ) + + class ExpectedInputSchema(BlockSchema): + in_key_a: Any = SchemaField(title="Key A", default="A", advanced=True) + in_key_b: Any = SchemaField(title="in_key_b", advanced=False) + + class ExpectedOutputSchema(BlockSchema): + out_key: Any = SchemaField( + description="This is an output key", + title="out_key", + advanced=False, + ) + + input_schema = created_graph.input_schema + input_schema["title"] = "ExpectedInputSchema" + assert input_schema == ExpectedInputSchema.jsonschema() + + # Add snapshot testing for the schemas + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(input_schema, indent=2, sort_keys=True), "grph_in_schm" + ) + + output_schema = created_graph.output_schema + output_schema["title"] = "ExpectedOutputSchema" + assert output_schema == ExpectedOutputSchema.jsonschema() + + # Add snapshot testing for the output schema + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(output_schema, indent=2, sort_keys=True), "grph_out_schm" + ) + + +@pytest.mark.asyncio(loop_scope="session") +async def test_clean_graph(server: SpinTestServer): + """ + Test the clean_graph function that: + 1. Clears input block values + 2. Removes credentials from nodes + """ + # Create a graph with input blocks and credentials + graph = Graph( + id="test_clean_graph", + name="Test Clean Graph", + description="Test graph cleaning", + nodes=[ + Node( + id="input_node", + block_id=AgentInputBlock().id, + input_default={ + "name": "test_input", + "value": "test value", + "description": "Test input description", + }, + ), + ], + links=[], + ) + + # Create graph and get model + create_graph = CreateGraph(graph=graph) + created_graph = await server.agent_server.test_create_graph( + create_graph, DEFAULT_USER_ID + ) + + # Clean the graph + created_graph = await server.agent_server.test_get_graph( + created_graph.id, created_graph.version, DEFAULT_USER_ID, for_export=True + ) + + # # Verify input block value is cleared + input_node = next( + n for n in created_graph.nodes if n.block_id == AgentInputBlock().id + ) + assert input_node.input_default["value"] == "" + + +@pytest.mark.asyncio(loop_scope="session") +async def test_access_store_listing_graph(server: SpinTestServer): + """ + Test the access of a store listing graph. + """ + graph = Graph( + id="test_clean_graph", + name="Test Clean Graph", + description="Test graph cleaning", + nodes=[ + Node( + id="input_node", + block_id=AgentInputBlock().id, + input_default={ + "name": "test_input", + "value": "test value", + "description": "Test input description", + }, + ), + ], + links=[], + ) + + # Create graph and get model + create_graph = CreateGraph(graph=graph) + created_graph = await server.agent_server.test_create_graph( + create_graph, DEFAULT_USER_ID + ) + + store_submission_request = store.StoreSubmissionRequest( + agent_id=created_graph.id, + agent_version=created_graph.version, + slug=created_graph.id, + name="Test name", + sub_heading="Test sub heading", + video_url=None, + image_urls=[], + description="Test description", + categories=[], + ) + + # First we check the graph an not be accessed by a different user + with pytest.raises(fastapi.exceptions.HTTPException) as exc_info: + await server.agent_server.test_get_graph( + created_graph.id, + created_graph.version, + "3e53486c-cf57-477e-ba2a-cb02dc828e1b", + ) + assert exc_info.value.status_code == 404 + assert "Graph" in str(exc_info.value.detail) + + # Now we create a store listing + store_listing = await server.agent_server.test_create_store_listing( + store_submission_request, DEFAULT_USER_ID + ) + + if isinstance(store_listing, fastapi.responses.JSONResponse): + assert False, "Failed to create store listing" + + slv_id = ( + store_listing.store_listing_version_id + if store_listing.store_listing_version_id is not None + else None + ) + + assert slv_id is not None + + admin_user = await create_test_user(alt_user=True) + await server.agent_server.test_review_store_listing( + store.ReviewSubmissionRequest( + store_listing_version_id=slv_id, + is_approved=True, + comments="Test comments", + ), + autogpt_libs.auth.models.User( + user_id=admin_user.id, + role="admin", + email=admin_user.email, + phone_number="1234567890", + ), + ) + + # Now we check the graph can be accessed by a user that does not own the graph + got_graph = await server.agent_server.test_get_graph( + created_graph.id, created_graph.version, "3e53486c-cf57-477e-ba2a-cb02dc828e1b" + ) + assert got_graph is not None diff --git a/autogpt_platform/backend/backend/data/includes.py b/autogpt_platform/backend/backend/data/includes.py index 0b791f502a1e..aec4f3667392 100644 --- a/autogpt_platform/backend/backend/data/includes.py +++ b/autogpt_platform/backend/backend/data/includes.py @@ -1,4 +1,7 @@ -import prisma +from typing import Sequence, cast + +import prisma.enums +import prisma.types AGENT_NODE_INCLUDE: prisma.types.AgentNodeInclude = { "Input": True, @@ -8,27 +11,67 @@ } AGENT_GRAPH_INCLUDE: prisma.types.AgentGraphInclude = { - "AgentNodes": {"include": AGENT_NODE_INCLUDE} # type: ignore + "Nodes": {"include": AGENT_NODE_INCLUDE} } +EXECUTION_RESULT_ORDER: list[prisma.types.AgentNodeExecutionOrderByInput] = [ + {"queuedTime": "desc"}, + # Fallback: Incomplete execs has no queuedTime. + {"addedTime": "desc"}, +] + EXECUTION_RESULT_INCLUDE: prisma.types.AgentNodeExecutionInclude = { - "Input": True, - "Output": True, - "AgentNode": True, - "AgentGraphExecution": True, + "Input": {"order_by": {"time": "asc"}}, + "Output": {"order_by": {"time": "asc"}}, + "Node": True, + "GraphExecution": True, } -GRAPH_EXECUTION_INCLUDE: prisma.types.AgentGraphExecutionInclude = { - "AgentNodeExecutions": { - "include": { - "Input": True, - "Output": True, - "AgentNode": True, - "AgentGraphExecution": True, - } +MAX_NODE_EXECUTIONS_FETCH = 1000 + +GRAPH_EXECUTION_INCLUDE_WITH_NODES: prisma.types.AgentGraphExecutionInclude = { + "NodeExecutions": { + "include": EXECUTION_RESULT_INCLUDE, + "order_by": EXECUTION_RESULT_ORDER, + "take": MAX_NODE_EXECUTIONS_FETCH, # Avoid loading excessive node executions. } } + +def graph_execution_include( + include_block_ids: Sequence[str], +) -> prisma.types.AgentGraphExecutionInclude: + return { + "NodeExecutions": { + **cast( + prisma.types.FindManyAgentNodeExecutionArgsFromAgentGraphExecution, + GRAPH_EXECUTION_INCLUDE_WITH_NODES["NodeExecutions"], # type: ignore + ), + "where": { + "Node": { + "is": {"AgentBlock": {"is": {"id": {"in": include_block_ids}}}} + }, + "NOT": [ + {"executionStatus": prisma.enums.AgentExecutionStatus.INCOMPLETE} + ], + }, + } + } + + INTEGRATION_WEBHOOK_INCLUDE: prisma.types.IntegrationWebhookInclude = { - "AgentNodes": {"include": AGENT_NODE_INCLUDE} # type: ignore + "AgentNodes": {"include": AGENT_NODE_INCLUDE}, + "AgentPresets": {"include": {"InputPresets": True}}, } + + +def library_agent_include(user_id: str) -> prisma.types.LibraryAgentInclude: + return { + "AgentGraph": { + "include": { + **AGENT_GRAPH_INCLUDE, + "Executions": {"where": {"userId": user_id}}, + } + }, + "Creator": True, + } diff --git a/autogpt_platform/backend/backend/data/integrations.py b/autogpt_platform/backend/backend/data/integrations.py index 95034d078b26..3efb96f1b35f 100644 --- a/autogpt_platform/backend/backend/data/integrations.py +++ b/autogpt_platform/backend/backend/data/integrations.py @@ -1,19 +1,25 @@ import logging -from typing import TYPE_CHECKING, AsyncGenerator, Optional +from typing import AsyncGenerator, Literal, Optional, overload -from prisma import Json from prisma.models import IntegrationWebhook +from prisma.types import ( + IntegrationWebhookCreateInput, + IntegrationWebhookUpdateInput, + IntegrationWebhookWhereInput, + Serializable, +) from pydantic import Field, computed_field +from backend.data.event_bus import AsyncRedisEventBus from backend.data.includes import INTEGRATION_WEBHOOK_INCLUDE -from backend.data.queue import AsyncRedisEventBus from backend.integrations.providers import ProviderName from backend.integrations.webhooks.utils import webhook_ingress_url +from backend.server.v2.library.model import LibraryAgentPreset +from backend.util.exceptions import NotFoundError +from backend.util.json import SafeJson from .db import BaseDbModel - -if TYPE_CHECKING: - from .graph import NodeModel +from .graph import NodeModel logger = logging.getLogger(__name__) @@ -30,17 +36,13 @@ class Webhook(BaseDbModel): provider_webhook_id: str - attached_nodes: Optional[list["NodeModel"]] = None - @computed_field @property def url(self) -> str: - return webhook_ingress_url(self.provider.value, self.id) + return webhook_ingress_url(self.provider, self.id) @staticmethod def from_db(webhook: IntegrationWebhook): - from .graph import NodeModel - return Webhook( id=webhook.id, user_id=webhook.userId, @@ -52,11 +54,26 @@ def from_db(webhook: IntegrationWebhook): config=dict(webhook.config), secret=webhook.secret, provider_webhook_id=webhook.providerWebhookId, - attached_nodes=( - [NodeModel.from_db(node) for node in webhook.AgentNodes] - if webhook.AgentNodes is not None - else None - ), + ) + + +class WebhookWithRelations(Webhook): + triggered_nodes: list[NodeModel] + triggered_presets: list[LibraryAgentPreset] + + @staticmethod + def from_db(webhook: IntegrationWebhook): + if webhook.AgentNodes is None or webhook.AgentPresets is None: + raise ValueError( + "AgentNodes and AgentPresets must be included in " + "IntegrationWebhook query with relations" + ) + return WebhookWithRelations( + **Webhook.from_db(webhook).model_dump(), + triggered_nodes=[NodeModel.from_db(node) for node in webhook.AgentNodes], + triggered_presets=[ + LibraryAgentPreset.from_db(preset) for preset in webhook.AgentPresets + ], ) @@ -65,91 +82,150 @@ def from_db(webhook: IntegrationWebhook): async def create_webhook(webhook: Webhook) -> Webhook: created_webhook = await IntegrationWebhook.prisma().create( - data={ - "id": webhook.id, - "userId": webhook.user_id, - "provider": webhook.provider.value, - "credentialsId": webhook.credentials_id, - "webhookType": webhook.webhook_type, - "resource": webhook.resource, - "events": webhook.events, - "config": Json(webhook.config), - "secret": webhook.secret, - "providerWebhookId": webhook.provider_webhook_id, - } + data=IntegrationWebhookCreateInput( + id=webhook.id, + userId=webhook.user_id, + provider=webhook.provider.value, + credentialsId=webhook.credentials_id, + webhookType=webhook.webhook_type, + resource=webhook.resource, + events=webhook.events, + config=SafeJson(webhook.config), + secret=webhook.secret, + providerWebhookId=webhook.provider_webhook_id, + ) ) return Webhook.from_db(created_webhook) -async def get_webhook(webhook_id: str) -> Webhook: - """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" - webhook = await IntegrationWebhook.prisma().find_unique_or_raise( +@overload +async def get_webhook( + webhook_id: str, *, include_relations: Literal[True] +) -> WebhookWithRelations: ... +@overload +async def get_webhook( + webhook_id: str, *, include_relations: Literal[False] = False +) -> Webhook: ... + + +async def get_webhook( + webhook_id: str, *, include_relations: bool = False +) -> Webhook | WebhookWithRelations: + """ + ⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints. + + Raises: + NotFoundError: if no record with the given ID exists + """ + webhook = await IntegrationWebhook.prisma().find_unique( where={"id": webhook_id}, - include=INTEGRATION_WEBHOOK_INCLUDE, + include=INTEGRATION_WEBHOOK_INCLUDE if include_relations else None, ) - return Webhook.from_db(webhook) + if not webhook: + raise NotFoundError(f"Webhook #{webhook_id} not found") + return (WebhookWithRelations if include_relations else Webhook).from_db(webhook) -async def get_all_webhooks_by_creds(credentials_id: str) -> list[Webhook]: - """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" +@overload +async def get_all_webhooks_by_creds( + user_id: str, credentials_id: str, *, include_relations: Literal[True] +) -> list[WebhookWithRelations]: ... +@overload +async def get_all_webhooks_by_creds( + user_id: str, credentials_id: str, *, include_relations: Literal[False] = False +) -> list[Webhook]: ... + + +async def get_all_webhooks_by_creds( + user_id: str, credentials_id: str, *, include_relations: bool = False +) -> list[Webhook] | list[WebhookWithRelations]: if not credentials_id: raise ValueError("credentials_id must not be empty") webhooks = await IntegrationWebhook.prisma().find_many( - where={"credentialsId": credentials_id}, - include=INTEGRATION_WEBHOOK_INCLUDE, + where={"userId": user_id, "credentialsId": credentials_id}, + include=INTEGRATION_WEBHOOK_INCLUDE if include_relations else None, ) - return [Webhook.from_db(webhook) for webhook in webhooks] + return [ + (WebhookWithRelations if include_relations else Webhook).from_db(webhook) + for webhook in webhooks + ] async def find_webhook_by_credentials_and_props( - credentials_id: str, webhook_type: str, resource: str, events: list[str] + user_id: str, + credentials_id: str, + webhook_type: str, + resource: str, + events: list[str], ) -> Webhook | None: - """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" webhook = await IntegrationWebhook.prisma().find_first( where={ + "userId": user_id, "credentialsId": credentials_id, "webhookType": webhook_type, "resource": resource, "events": {"has_every": events}, }, - include=INTEGRATION_WEBHOOK_INCLUDE, ) return Webhook.from_db(webhook) if webhook else None async def find_webhook_by_graph_and_props( - graph_id: str, provider: str, webhook_type: str, events: list[str] + user_id: str, + provider: str, + webhook_type: str, + graph_id: Optional[str] = None, + preset_id: Optional[str] = None, ) -> Webhook | None: - """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" + """Either `graph_id` or `preset_id` must be provided.""" + where_clause: IntegrationWebhookWhereInput = { + "userId": user_id, + "provider": provider, + "webhookType": webhook_type, + } + + if preset_id: + where_clause["AgentPresets"] = {"some": {"id": preset_id}} + elif graph_id: + where_clause["AgentNodes"] = {"some": {"agentGraphId": graph_id}} + else: + raise ValueError("Either graph_id or preset_id must be provided") + webhook = await IntegrationWebhook.prisma().find_first( - where={ - "provider": provider, - "webhookType": webhook_type, - "events": {"has_every": events}, - "AgentNodes": {"some": {"agentGraphId": graph_id}}, - }, - include=INTEGRATION_WEBHOOK_INCLUDE, + where=where_clause, ) return Webhook.from_db(webhook) if webhook else None -async def update_webhook_config(webhook_id: str, updated_config: dict) -> Webhook: +async def update_webhook( + webhook_id: str, + config: Optional[dict[str, Serializable]] = None, + events: Optional[list[str]] = None, +) -> Webhook: """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" + data: IntegrationWebhookUpdateInput = {} + if config is not None: + data["config"] = SafeJson(config) + if events is not None: + data["events"] = events + if not data: + raise ValueError("Empty update query") + _updated_webhook = await IntegrationWebhook.prisma().update( where={"id": webhook_id}, - data={"config": Json(updated_config)}, - include=INTEGRATION_WEBHOOK_INCLUDE, + data=data, ) if _updated_webhook is None: - raise ValueError(f"Webhook #{webhook_id} not found") + raise NotFoundError(f"Webhook #{webhook_id} not found") return Webhook.from_db(_updated_webhook) -async def delete_webhook(webhook_id: str) -> None: - """⚠️ No `user_id` check: DO NOT USE without check in user-facing endpoints.""" - deleted = await IntegrationWebhook.prisma().delete(where={"id": webhook_id}) - if not deleted: - raise ValueError(f"Webhook #{webhook_id} not found") +async def delete_webhook(user_id: str, webhook_id: str) -> None: + deleted = await IntegrationWebhook.prisma().delete_many( + where={"id": webhook_id, "userId": user_id} + ) + if deleted < 1: + raise NotFoundError(f"Webhook #{webhook_id} not found") # --------------------- WEBHOOK EVENTS --------------------- # diff --git a/autogpt_platform/backend/backend/data/model.py b/autogpt_platform/backend/backend/data/model.py index 898ef1040b0c..01602ec1d458 100644 --- a/autogpt_platform/backend/backend/data/model.py +++ b/autogpt_platform/backend/backend/data/model.py @@ -1,6 +1,11 @@ from __future__ import annotations +import base64 +import enum import logging +from collections import defaultdict +from datetime import datetime, timezone +from json import JSONDecodeError from typing import ( TYPE_CHECKING, Annotated, @@ -10,12 +15,14 @@ Generic, Literal, Optional, - TypedDict, TypeVar, + cast, get_args, ) +from urllib.parse import urlparse from uuid import uuid4 +from prisma.enums import CreditTransactionType from pydantic import ( BaseModel, ConfigDict, @@ -31,11 +38,130 @@ ValidationError, core_schema, ) +from typing_extensions import TypedDict from backend.integrations.providers import ProviderName +from backend.util.json import loads as json_loads from backend.util.settings import Secrets +# Type alias for any provider name (including custom ones) +AnyProviderName = str # Will be validated as ProviderName at runtime + + +class User(BaseModel): + """Application-layer User model with snake_case convention.""" + + model_config = ConfigDict( + extra="forbid", + str_strip_whitespace=True, + ) + + id: str = Field(..., description="User ID") + email: str = Field(..., description="User email address") + email_verified: bool = Field(default=True, description="Whether email is verified") + name: Optional[str] = Field(None, description="User display name") + created_at: datetime = Field(..., description="When user was created") + updated_at: datetime = Field(..., description="When user was last updated") + metadata: dict[str, Any] = Field( + default_factory=dict, description="User metadata as dict" + ) + integrations: str = Field(default="", description="Encrypted integrations data") + stripe_customer_id: Optional[str] = Field(None, description="Stripe customer ID") + top_up_config: Optional["AutoTopUpConfig"] = Field( + None, description="Top up configuration" + ) + + # Notification preferences + max_emails_per_day: int = Field(default=3, description="Maximum emails per day") + notify_on_agent_run: bool = Field(default=True, description="Notify on agent run") + notify_on_zero_balance: bool = Field( + default=True, description="Notify on zero balance" + ) + notify_on_low_balance: bool = Field( + default=True, description="Notify on low balance" + ) + notify_on_block_execution_failed: bool = Field( + default=True, description="Notify on block execution failure" + ) + notify_on_continuous_agent_error: bool = Field( + default=True, description="Notify on continuous agent error" + ) + notify_on_daily_summary: bool = Field( + default=True, description="Notify on daily summary" + ) + notify_on_weekly_summary: bool = Field( + default=True, description="Notify on weekly summary" + ) + notify_on_monthly_summary: bool = Field( + default=True, description="Notify on monthly summary" + ) + + # User timezone for scheduling and time display + timezone: str = Field( + default="not-set", + description="User timezone (IANA timezone identifier or 'not-set')", + ) + + @classmethod + def from_db(cls, prisma_user: "PrismaUser") -> "User": + """Convert a database User object to application User model.""" + # Handle metadata field - convert from JSON string or dict to dict + metadata = {} + if prisma_user.metadata: + if isinstance(prisma_user.metadata, str): + try: + metadata = json_loads(prisma_user.metadata) + except (JSONDecodeError, TypeError): + metadata = {} + elif isinstance(prisma_user.metadata, dict): + metadata = prisma_user.metadata + + # Handle topUpConfig field + top_up_config = None + if prisma_user.topUpConfig: + if isinstance(prisma_user.topUpConfig, str): + try: + config_dict = json_loads(prisma_user.topUpConfig) + top_up_config = AutoTopUpConfig.model_validate(config_dict) + except (JSONDecodeError, TypeError, ValueError): + top_up_config = None + elif isinstance(prisma_user.topUpConfig, dict): + try: + top_up_config = AutoTopUpConfig.model_validate( + prisma_user.topUpConfig + ) + except ValueError: + top_up_config = None + + return cls( + id=prisma_user.id, + email=prisma_user.email, + email_verified=prisma_user.emailVerified or True, + name=prisma_user.name, + created_at=prisma_user.createdAt, + updated_at=prisma_user.updatedAt, + metadata=metadata, + integrations=prisma_user.integrations or "", + stripe_customer_id=prisma_user.stripeCustomerId, + top_up_config=top_up_config, + max_emails_per_day=prisma_user.maxEmailsPerDay or 3, + notify_on_agent_run=prisma_user.notifyOnAgentRun or True, + notify_on_zero_balance=prisma_user.notifyOnZeroBalance or True, + notify_on_low_balance=prisma_user.notifyOnLowBalance or True, + notify_on_block_execution_failed=prisma_user.notifyOnBlockExecutionFailed + or True, + notify_on_continuous_agent_error=prisma_user.notifyOnContinuousAgentError + or True, + notify_on_daily_summary=prisma_user.notifyOnDailySummary or True, + notify_on_weekly_summary=prisma_user.notifyOnWeeklySummary or True, + notify_on_monthly_summary=prisma_user.notifyOnMonthlySummary or True, + timezone=prisma_user.timezone or "not-set", + ) + + if TYPE_CHECKING: + from prisma.models import User as PrismaUser + from backend.data.block import BlockSchema T = TypeVar("T") @@ -134,14 +260,24 @@ def SchemaField( title: Optional[str] = None, description: Optional[str] = None, placeholder: Optional[str] = None, - advanced: Optional[bool] = False, + advanced: Optional[bool] = None, secret: bool = False, exclude: bool = False, hidden: Optional[bool] = None, - depends_on: list[str] | None = None, - **kwargs, + depends_on: Optional[list[str]] = None, + ge: Optional[float] = None, + le: Optional[float] = None, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + discriminator: Optional[str] = None, + json_schema_extra: Optional[dict[str, Any]] = None, ) -> T: - json_extra = { + if default is PydanticUndefined and default_factory is None: + advanced = False + elif advanced is None: + advanced = True + + json_schema_extra = { k: v for k, v in { "placeholder": placeholder, @@ -149,6 +285,7 @@ def SchemaField( "advanced": advanced, "hidden": hidden, "depends_on": depends_on, + **(json_schema_extra or {}), }.items() if v is not None } @@ -160,15 +297,19 @@ def SchemaField( title=title, description=description, exclude=exclude, - json_schema_extra=json_extra, - **kwargs, + ge=ge, + le=le, + min_length=min_length, + max_length=max_length, + discriminator=discriminator, + json_schema_extra=json_schema_extra, ) # type: ignore class _BaseCredentials(BaseModel): id: str = Field(default_factory=lambda: str(uuid4())) provider: str - title: Optional[str] + title: Optional[str] = None @field_serializer("*") def dump_secret_strings(value: Any, _info): @@ -179,59 +320,144 @@ def dump_secret_strings(value: Any, _info): class OAuth2Credentials(_BaseCredentials): type: Literal["oauth2"] = "oauth2" - username: Optional[str] + username: Optional[str] = None """Username of the third-party service user that these credentials belong to""" access_token: SecretStr - access_token_expires_at: Optional[int] + access_token_expires_at: Optional[int] = None """Unix timestamp (seconds) indicating when the access token expires (if at all)""" - refresh_token: Optional[SecretStr] - refresh_token_expires_at: Optional[int] + refresh_token: Optional[SecretStr] = None + refresh_token_expires_at: Optional[int] = None """Unix timestamp (seconds) indicating when the refresh token expires (if at all)""" scopes: list[str] metadata: dict[str, Any] = Field(default_factory=dict) - def bearer(self) -> str: + def auth_header(self) -> str: return f"Bearer {self.access_token.get_secret_value()}" class APIKeyCredentials(_BaseCredentials): type: Literal["api_key"] = "api_key" api_key: SecretStr - expires_at: Optional[int] + expires_at: Optional[int] = Field( + default=None, + description="Unix timestamp (seconds) indicating when the API key expires (if at all)", + ) """Unix timestamp (seconds) indicating when the API key expires (if at all)""" - def bearer(self) -> str: + def auth_header(self) -> str: return f"Bearer {self.api_key.get_secret_value()}" +class UserPasswordCredentials(_BaseCredentials): + type: Literal["user_password"] = "user_password" + username: SecretStr + password: SecretStr + + def auth_header(self) -> str: + # Converting the string to bytes using encode() + # Base64 encoding it with base64.b64encode() + # Converting the resulting bytes back to a string with decode() + return f"Basic {base64.b64encode(f'{self.username.get_secret_value()}:{self.password.get_secret_value()}'.encode()).decode()}" + + +class HostScopedCredentials(_BaseCredentials): + type: Literal["host_scoped"] = "host_scoped" + host: str = Field(description="The host/URI pattern to match against request URLs") + headers: dict[str, SecretStr] = Field( + description="Key-value header map to add to matching requests", + default_factory=dict, + ) + + def _extract_headers(self, headers: dict[str, SecretStr]) -> dict[str, str]: + """Helper to extract secret values from headers.""" + return {key: value.get_secret_value() for key, value in headers.items()} + + @field_serializer("headers") + def serialize_headers(self, headers: dict[str, SecretStr]) -> dict[str, str]: + """Serialize headers by extracting secret values.""" + return self._extract_headers(headers) + + def get_headers_dict(self) -> dict[str, str]: + """Get headers with secret values extracted.""" + return self._extract_headers(self.headers) + + def auth_header(self) -> str: + """Get authorization header for backward compatibility.""" + auth_headers = self.get_headers_dict() + if "Authorization" in auth_headers: + return auth_headers["Authorization"] + return "" + + def matches_url(self, url: str) -> bool: + """Check if this credential should be applied to the given URL.""" + + parsed_url = urlparse(url) + # Extract hostname without port + request_host = parsed_url.hostname + if not request_host: + return False + + # Simple host matching - exact match or wildcard subdomain match + if self.host == request_host: + return True + + # Support wildcard matching (e.g., "*.example.com" matches "api.example.com") + if self.host.startswith("*."): + domain = self.host[2:] # Remove "*." + return request_host.endswith(f".{domain}") or request_host == domain + + return False + + Credentials = Annotated[ - OAuth2Credentials | APIKeyCredentials, + OAuth2Credentials + | APIKeyCredentials + | UserPasswordCredentials + | HostScopedCredentials, Field(discriminator="type"), ] -CredentialsType = Literal["api_key", "oauth2"] +CredentialsType = Literal["api_key", "oauth2", "user_password", "host_scoped"] class OAuthState(BaseModel): token: str provider: str expires_at: int + code_verifier: Optional[str] = None """Unix timestamp (seconds) indicating when this OAuth state expires""" scopes: list[str] class UserMetadata(BaseModel): integration_credentials: list[Credentials] = Field(default_factory=list) + """⚠️ Deprecated; use `UserIntegrations.credentials` instead""" integration_oauth_states: list[OAuthState] = Field(default_factory=list) + """⚠️ Deprecated; use `UserIntegrations.oauth_states` instead""" class UserMetadataRaw(TypedDict, total=False): integration_credentials: list[dict] + """⚠️ Deprecated; use `UserIntegrations.credentials` instead""" integration_oauth_states: list[dict] + """⚠️ Deprecated; use `UserIntegrations.oauth_states` instead""" class UserIntegrations(BaseModel): + + class ManagedCredentials(BaseModel): + """Integration credentials managed by us, rather than by the user""" + + ayrshare_profile_key: Optional[SecretStr] = None + + @field_serializer("*") + def dump_secret_strings(value: Any, _info): + if isinstance(value, SecretStr): + return value.get_secret_value() + return value + + managed_credentials: ManagedCredentials = Field(default_factory=ManagedCredentials) credentials: list[Credentials] = Field(default_factory=list) oauth_states: list[OAuthState] = Field(default_factory=list) @@ -240,7 +466,8 @@ class UserIntegrations(BaseModel): CT = TypeVar("CT", bound=CredentialsType) -CREDENTIALS_FIELD_NAME = "credentials" +def is_credentials_field_name(field_name: str) -> bool: + return field_name == "credentials" or field_name.endswith("_credentials") class CredentialsMetaInput(BaseModel, Generic[CP, CT]): @@ -249,25 +476,23 @@ class CredentialsMetaInput(BaseModel, Generic[CP, CT]): provider: CP type: CT - @staticmethod - def _add_json_schema_extra(schema, cls: CredentialsMetaInput): - schema["credentials_provider"] = get_args( - cls.model_fields["provider"].annotation - ) - schema["credentials_types"] = get_args(cls.model_fields["type"].annotation) + @classmethod + def allowed_providers(cls) -> tuple[ProviderName, ...] | None: + return get_args(cls.model_fields["provider"].annotation) - model_config = ConfigDict( - json_schema_extra=_add_json_schema_extra, # type: ignore - ) + @classmethod + def allowed_cred_types(cls) -> tuple[CredentialsType, ...]: + return get_args(cls.model_fields["type"].annotation) @classmethod def validate_credentials_field_schema(cls, model: type["BlockSchema"]): - """Validates the schema of a `credentials` field""" - field_schema = model.jsonschema()["properties"][CREDENTIALS_FIELD_NAME] + """Validates the schema of a credentials input field""" + field_name = next( + name for name, type in model.get_credentials_fields().items() if type is cls + ) + field_schema = model.jsonschema()["properties"][field_name] try: - schema_extra = _CredentialsFieldSchemaExtra[CP, CT].model_validate( - field_schema - ) + schema_extra = CredentialsFieldInfo[CP, CT].model_validate(field_schema) except ValidationError as e: if "Field required [type=missing" not in str(e): raise @@ -277,20 +502,162 @@ def validate_credentials_field_schema(cls, model: type["BlockSchema"]): f"{field_schema}" ) from e + providers = cls.allowed_providers() if ( - len(schema_extra.credentials_provider) > 1 + providers is not None + and len(providers) > 1 and not schema_extra.discriminator ): - raise TypeError("Multi-provider CredentialsField requires discriminator!") + raise TypeError( + f"Multi-provider CredentialsField '{field_name}' " + "requires discriminator!" + ) + + @staticmethod + def _add_json_schema_extra(schema: dict, model_class: type): + # Use model_class for allowed_providers/cred_types + if hasattr(model_class, "allowed_providers") and hasattr( + model_class, "allowed_cred_types" + ): + allowed_providers = model_class.allowed_providers() + # If no specific providers (None), allow any string + if allowed_providers is None: + schema["credentials_provider"] = ["string"] # Allow any string provider + else: + schema["credentials_provider"] = allowed_providers + schema["credentials_types"] = model_class.allowed_cred_types() + # Do not return anything, just mutate schema in place + + model_config = ConfigDict( + json_schema_extra=_add_json_schema_extra, # type: ignore + ) + + +def _extract_host_from_url(url: str) -> str: + """Extract host from URL for grouping host-scoped credentials.""" + try: + parsed = urlparse(url) + return parsed.hostname or url + except Exception: + return "" -class _CredentialsFieldSchemaExtra(BaseModel, Generic[CP, CT]): +class CredentialsFieldInfo(BaseModel, Generic[CP, CT]): # TODO: move discrimination mechanism out of CredentialsField (frontend + backend) - credentials_provider: list[CP] - credentials_scopes: Optional[list[str]] = None - credentials_types: list[CT] + provider: frozenset[CP] = Field(..., alias="credentials_provider") + supported_types: frozenset[CT] = Field(..., alias="credentials_types") + required_scopes: Optional[frozenset[str]] = Field(None, alias="credentials_scopes") discriminator: Optional[str] = None discriminator_mapping: Optional[dict[str, CP]] = None + discriminator_values: set[Any] = Field(default_factory=set) + + @classmethod + def combine( + cls, *fields: tuple[CredentialsFieldInfo[CP, CT], T] + ) -> dict[str, tuple[CredentialsFieldInfo[CP, CT], set[T]]]: + """ + Combines multiple CredentialsFieldInfo objects into as few as possible. + + Rules: + - Items can only be combined if they have the same supported credentials types + and the same supported providers. + - When combining items, the `required_scopes` of the result is a join + of the `required_scopes` of the original items. + + Params: + *fields: (CredentialsFieldInfo, key) objects to group and combine + + Returns: + A sequence of tuples containing combined CredentialsFieldInfo objects and + the set of keys of the respective original items that were grouped together. + """ + if not fields: + return {} + + # Group fields by their provider and supported_types + # For HTTP host-scoped credentials, also group by host + grouped_fields: defaultdict[ + tuple[frozenset[CP], frozenset[CT]], + list[tuple[T, CredentialsFieldInfo[CP, CT]]], + ] = defaultdict(list) + + for field, key in fields: + if field.provider == frozenset([ProviderName.HTTP]): + # HTTP host-scoped credentials can have different hosts that reqires different credential sets. + # Group by host extracted from the URL + providers = frozenset( + [cast(CP, "http")] + + [ + cast(CP, _extract_host_from_url(str(value))) + for value in field.discriminator_values + ] + ) + else: + providers = frozenset(field.provider) + + group_key = (providers, frozenset(field.supported_types)) + grouped_fields[group_key].append((key, field)) + + # Combine fields within each group + result: dict[str, tuple[CredentialsFieldInfo[CP, CT], set[T]]] = {} + + for key, group in grouped_fields.items(): + # Start with the first field in the group + _, combined = group[0] + + # Track the keys that were combined + combined_keys = {key for key, _ in group} + + # Combine required_scopes from all fields in the group + all_scopes = set() + for _, field in group: + if field.required_scopes: + all_scopes.update(field.required_scopes) + + # Combine discriminator_values from all fields in the group (removing duplicates) + all_discriminator_values = [] + for _, field in group: + for value in field.discriminator_values: + if value not in all_discriminator_values: + all_discriminator_values.append(value) + + # Generate the key for the combined result + providers_key, supported_types_key = key + group_key = ( + "-".join(sorted(providers_key)) + + "_" + + "-".join(sorted(supported_types_key)) + + "_credentials" + ) + + result[group_key] = ( + CredentialsFieldInfo[CP, CT]( + credentials_provider=combined.provider, + credentials_types=combined.supported_types, + credentials_scopes=frozenset(all_scopes) or None, + discriminator=combined.discriminator, + discriminator_mapping=combined.discriminator_mapping, + discriminator_values=set(all_discriminator_values), + ), + combined_keys, + ) + + return result + + def discriminate(self, discriminator_value: Any) -> CredentialsFieldInfo: + if not (self.discriminator and self.discriminator_mapping): + return self + + return CredentialsFieldInfo( + credentials_provider=frozenset( + [self.discriminator_mapping[discriminator_value]] + ), + credentials_types=self.supported_types, + credentials_scopes=self.required_scopes, + discriminator=self.discriminator, + discriminator_mapping=self.discriminator_mapping, + discriminator_values=self.discriminator_values, + ) def CredentialsField( @@ -298,6 +665,7 @@ def CredentialsField( *, discriminator: Optional[str] = None, discriminator_mapping: Optional[dict[str, Any]] = None, + discriminator_values: Optional[set[Any]] = None, title: Optional[str] = None, description: Optional[str] = None, **kwargs, @@ -313,10 +681,16 @@ def CredentialsField( "credentials_scopes": list(required_scopes) or None, "discriminator": discriminator, "discriminator_mapping": discriminator_mapping, + "discriminator_values": discriminator_values, }.items() if v is not None } + # Merge any json_schema_extra passed in kwargs + if "json_schema_extra" in kwargs: + extra_schema = kwargs.pop("json_schema_extra") + field_schema_extra.update(extra_schema) + return Field( title=title, description=description, @@ -327,3 +701,148 @@ def CredentialsField( class ContributorDetails(BaseModel): name: str = Field(title="Name", description="The name of the contributor.") + + +class TopUpType(enum.Enum): + AUTO = "AUTO" + MANUAL = "MANUAL" + UNCATEGORIZED = "UNCATEGORIZED" + + +class AutoTopUpConfig(BaseModel): + amount: int + """Amount of credits to top up.""" + threshold: int + """Threshold to trigger auto top up.""" + + +class UserTransaction(BaseModel): + transaction_key: str = "" + transaction_time: datetime = datetime.min.replace(tzinfo=timezone.utc) + transaction_type: CreditTransactionType = CreditTransactionType.USAGE + amount: int = 0 + running_balance: int = 0 + current_balance: int = 0 + description: str | None = None + usage_graph_id: str | None = None + usage_execution_id: str | None = None + usage_node_count: int = 0 + usage_start_time: datetime = datetime.max.replace(tzinfo=timezone.utc) + user_id: str + user_email: str | None = None + reason: str | None = None + admin_email: str | None = None + extra_data: str | None = None + + +class TransactionHistory(BaseModel): + transactions: list[UserTransaction] + next_transaction_time: datetime | None + + +class RefundRequest(BaseModel): + id: str + user_id: str + transaction_key: str + amount: int + reason: str + result: str | None = None + status: str + created_at: datetime + updated_at: datetime + + +class NodeExecutionStats(BaseModel): + """Execution statistics for a node execution.""" + + model_config = ConfigDict( + extra="allow", + arbitrary_types_allowed=True, + ) + + error: Optional[BaseException | str] = None + walltime: float = 0 + cputime: float = 0 + input_size: int = 0 + output_size: int = 0 + llm_call_count: int = 0 + llm_retry_count: int = 0 + input_token_count: int = 0 + output_token_count: int = 0 + extra_cost: int = 0 + extra_steps: int = 0 + # Moderation fields + cleared_inputs: Optional[dict[str, list[str]]] = None + cleared_outputs: Optional[dict[str, list[str]]] = None + + def __iadd__(self, other: "NodeExecutionStats") -> "NodeExecutionStats": + """Mutate this instance by adding another NodeExecutionStats.""" + if not isinstance(other, NodeExecutionStats): + return NotImplemented + + stats_dict = other.model_dump() + current_stats = self.model_dump() + + for key, value in stats_dict.items(): + if key not in current_stats: + # Field doesn't exist yet, just set it + setattr(self, key, value) + elif isinstance(value, dict) and isinstance(current_stats[key], dict): + current_stats[key].update(value) + setattr(self, key, current_stats[key]) + elif isinstance(value, (int, float)) and isinstance( + current_stats[key], (int, float) + ): + setattr(self, key, current_stats[key] + value) + elif isinstance(value, list) and isinstance(current_stats[key], list): + current_stats[key].extend(value) + setattr(self, key, current_stats[key]) + else: + setattr(self, key, value) + + return self + + +class GraphExecutionStats(BaseModel): + """Execution statistics for a graph execution.""" + + model_config = ConfigDict( + extra="allow", + arbitrary_types_allowed=True, + ) + + error: Optional[Exception | str] = None + walltime: float = Field( + default=0, description="Time between start and end of run (seconds)" + ) + cputime: float = 0 + nodes_walltime: float = Field( + default=0, description="Total node execution time (seconds)" + ) + nodes_cputime: float = 0 + node_count: int = Field(default=0, description="Total number of node executions") + node_error_count: int = Field( + default=0, description="Total number of errors generated" + ) + cost: int = Field(default=0, description="Total execution cost (cents)") + activity_status: Optional[str] = Field( + default=None, description="AI-generated summary of what the agent did" + ) + + +class UserExecutionSummaryStats(BaseModel): + """Summary of user statistics for a specific user.""" + + model_config = ConfigDict( + extra="allow", + arbitrary_types_allowed=True, + ) + + total_credits_used: float = Field(default=0) + total_executions: int = Field(default=0) + successful_runs: int = Field(default=0) + failed_runs: int = Field(default=0) + most_used_agent: str = Field(default="") + total_execution_time: float = Field(default=0) + average_execution_time: float = Field(default=0) + cost_breakdown: dict[str, float] = Field(default_factory=dict) diff --git a/autogpt_platform/backend/backend/data/model_test.py b/autogpt_platform/backend/backend/data/model_test.py new file mode 100644 index 000000000000..37ec6be82f8a --- /dev/null +++ b/autogpt_platform/backend/backend/data/model_test.py @@ -0,0 +1,143 @@ +import pytest +from pydantic import SecretStr + +from backend.data.model import HostScopedCredentials + + +class TestHostScopedCredentials: + def test_host_scoped_credentials_creation(self): + """Test creating HostScopedCredentials with required fields.""" + creds = HostScopedCredentials( + provider="custom", + host="api.example.com", + headers={ + "Authorization": SecretStr("Bearer secret-token"), + "X-API-Key": SecretStr("api-key-123"), + }, + title="Example API Credentials", + ) + + assert creds.type == "host_scoped" + assert creds.provider == "custom" + assert creds.host == "api.example.com" + assert creds.title == "Example API Credentials" + assert len(creds.headers) == 2 + assert "Authorization" in creds.headers + assert "X-API-Key" in creds.headers + + def test_get_headers_dict(self): + """Test getting headers with secret values extracted.""" + creds = HostScopedCredentials( + provider="custom", + host="api.example.com", + headers={ + "Authorization": SecretStr("Bearer secret-token"), + "X-Custom-Header": SecretStr("custom-value"), + }, + ) + + headers_dict = creds.get_headers_dict() + + assert headers_dict == { + "Authorization": "Bearer secret-token", + "X-Custom-Header": "custom-value", + } + + def test_matches_url_exact_host(self): + """Test URL matching with exact host match.""" + creds = HostScopedCredentials( + provider="custom", + host="api.example.com", + headers={"Authorization": SecretStr("Bearer token")}, + ) + + assert creds.matches_url("https://api.example.com/v1/data") + assert creds.matches_url("http://api.example.com/endpoint") + assert not creds.matches_url("https://other.example.com/v1/data") + assert not creds.matches_url("https://subdomain.api.example.com/v1/data") + + def test_matches_url_wildcard_subdomain(self): + """Test URL matching with wildcard subdomain pattern.""" + creds = HostScopedCredentials( + provider="custom", + host="*.example.com", + headers={"Authorization": SecretStr("Bearer token")}, + ) + + assert creds.matches_url("https://api.example.com/v1/data") + assert creds.matches_url("https://subdomain.example.com/endpoint") + assert creds.matches_url("https://deep.nested.example.com/path") + assert creds.matches_url("https://example.com/path") # Base domain should match + assert not creds.matches_url("https://example.org/v1/data") + assert not creds.matches_url("https://notexample.com/v1/data") + + def test_matches_url_with_port_and_path(self): + """Test URL matching with ports and paths.""" + creds = HostScopedCredentials( + provider="custom", + host="localhost", + headers={"Authorization": SecretStr("Bearer token")}, + ) + + assert creds.matches_url("http://localhost:8080/api/v1") + assert creds.matches_url("https://localhost:443/secure/endpoint") + assert creds.matches_url("http://localhost/simple") + + def test_empty_headers_dict(self): + """Test HostScopedCredentials with empty headers.""" + creds = HostScopedCredentials( + provider="custom", host="api.example.com", headers={} + ) + + assert creds.get_headers_dict() == {} + assert creds.matches_url("https://api.example.com/test") + + def test_credential_serialization(self): + """Test that credentials can be serialized/deserialized properly.""" + original_creds = HostScopedCredentials( + provider="custom", + host="api.example.com", + headers={ + "Authorization": SecretStr("Bearer secret-token"), + "X-API-Key": SecretStr("api-key-123"), + }, + title="Test Credentials", + ) + + # Serialize to dict (simulating storage) + serialized = original_creds.model_dump() + + # Deserialize back + restored_creds = HostScopedCredentials.model_validate(serialized) + + assert restored_creds.id == original_creds.id + assert restored_creds.provider == original_creds.provider + assert restored_creds.host == original_creds.host + assert restored_creds.title == original_creds.title + assert restored_creds.type == "host_scoped" + + # Check that headers are properly restored + assert restored_creds.get_headers_dict() == original_creds.get_headers_dict() + + @pytest.mark.parametrize( + "host,test_url,expected", + [ + ("api.example.com", "https://api.example.com/test", True), + ("api.example.com", "https://different.example.com/test", False), + ("*.example.com", "https://api.example.com/test", True), + ("*.example.com", "https://sub.api.example.com/test", True), + ("*.example.com", "https://example.com/test", True), + ("*.example.com", "https://example.org/test", False), + ("localhost", "http://localhost:3000/test", True), + ("localhost", "http://127.0.0.1:3000/test", False), + ], + ) + def test_url_matching_parametrized(self, host: str, test_url: str, expected: bool): + """Parametrized test for various URL matching scenarios.""" + creds = HostScopedCredentials( + provider="test", + host=host, + headers={"Authorization": SecretStr("Bearer token")}, + ) + + assert creds.matches_url(test_url) == expected diff --git a/autogpt_platform/backend/backend/data/notifications.py b/autogpt_platform/backend/backend/data/notifications.py new file mode 100644 index 000000000000..0be2fada98f5 --- /dev/null +++ b/autogpt_platform/backend/backend/data/notifications.py @@ -0,0 +1,577 @@ +import logging +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Annotated, Any, Generic, Optional, TypeVar, Union + +from prisma import Json +from prisma.enums import NotificationType +from prisma.models import NotificationEvent, UserNotificationBatch +from prisma.types import ( + NotificationEventCreateInput, + UserNotificationBatchCreateInput, + UserNotificationBatchWhereInput, +) + +# from backend.notifications.models import NotificationEvent +from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator + +from backend.server.v2.store.exceptions import DatabaseError +from backend.util.json import SafeJson +from backend.util.logging import TruncatedLogger + +from .db import transaction + +logger = TruncatedLogger(logging.getLogger(__name__), prefix="[NotificationService]") + + +NotificationDataType_co = TypeVar( + "NotificationDataType_co", bound="BaseNotificationData", covariant=True +) +SummaryParamsType_co = TypeVar( + "SummaryParamsType_co", bound="BaseSummaryParams", covariant=True +) + + +class QueueType(Enum): + IMMEDIATE = "immediate" # Send right away (errors, critical notifications) + BATCH = "batch" # Batch for up to an hour (usage reports) + SUMMARY = "summary" # Daily digest (summary notifications) + BACKOFF = "backoff" # Backoff strategy (exponential backoff) + ADMIN = "admin" # Admin notifications (errors, critical notifications) + + +class BaseNotificationData(BaseModel): + model_config = ConfigDict(extra="allow") + + +class AgentRunData(BaseNotificationData): + agent_name: str + credits_used: float + execution_time: float + node_count: int = Field(..., description="Number of nodes executed") + graph_id: str + outputs: list[dict[str, Any]] = Field(..., description="Outputs of the agent") + + +class ZeroBalanceData(BaseNotificationData): + agent_name: str = Field(..., description="Name of the agent") + current_balance: float = Field( + ..., description="Current balance in credits (100 = $1)" + ) + billing_page_link: str = Field(..., description="Link to billing page") + shortfall: float = Field(..., description="Amount of credits needed to continue") + + +class LowBalanceData(BaseNotificationData): + current_balance: float = Field( + ..., description="Current balance in credits (100 = $1)" + ) + billing_page_link: str = Field(..., description="Link to billing page") + + +class BlockExecutionFailedData(BaseNotificationData): + block_name: str + block_id: str + error_message: str + graph_id: str + node_id: str + execution_id: str + + +class ContinuousAgentErrorData(BaseNotificationData): + agent_name: str + error_message: str + graph_id: str + execution_id: str + start_time: datetime + error_time: datetime + attempts: int = Field(..., description="Number of retry attempts made") + + @field_validator("start_time", "error_time") + @classmethod + def validate_timezone(cls, value: datetime): + if value.tzinfo is None: + raise ValueError("datetime must have timezone information") + return value + + +class BaseSummaryData(BaseNotificationData): + total_credits_used: float + total_executions: int + most_used_agent: str + total_execution_time: float + successful_runs: int + failed_runs: int + average_execution_time: float + cost_breakdown: dict[str, float] + + +class BaseSummaryParams(BaseModel): + start_date: datetime + end_date: datetime + + @field_validator("start_date", "end_date") + def validate_timezone(cls, value): + if value.tzinfo is None: + raise ValueError("datetime must have timezone information") + return value + + +class DailySummaryParams(BaseSummaryParams): + date: datetime + + @field_validator("date") + def validate_timezone(cls, value): + if value.tzinfo is None: + raise ValueError("datetime must have timezone information") + return value + + +class WeeklySummaryParams(BaseSummaryParams): + start_date: datetime + end_date: datetime + + @field_validator("start_date", "end_date") + def validate_timezone(cls, value): + if value.tzinfo is None: + raise ValueError("datetime must have timezone information") + return value + + +class DailySummaryData(BaseSummaryData): + date: datetime + + @field_validator("date") + def validate_timezone(cls, value): + if value.tzinfo is None: + raise ValueError("datetime must have timezone information") + return value + + +class WeeklySummaryData(BaseSummaryData): + start_date: datetime + end_date: datetime + + @field_validator("start_date", "end_date") + def validate_timezone(cls, value): + if value.tzinfo is None: + raise ValueError("datetime must have timezone information") + return value + + +class MonthlySummaryData(BaseNotificationData): + month: int + year: int + + +class RefundRequestData(BaseNotificationData): + user_id: str + user_name: str + user_email: str + transaction_id: str + refund_request_id: str + reason: str + amount: float + balance: int + + +class AgentApprovalData(BaseNotificationData): + agent_name: str + agent_id: str + agent_version: int + reviewer_name: str + reviewer_email: str + comments: str + reviewed_at: datetime + store_url: str + + @field_validator("reviewed_at") + @classmethod + def validate_timezone(cls, value: datetime): + if value.tzinfo is None: + raise ValueError("datetime must have timezone information") + return value + + +class AgentRejectionData(BaseNotificationData): + agent_name: str + agent_id: str + agent_version: int + reviewer_name: str + reviewer_email: str + comments: str + reviewed_at: datetime + resubmit_url: str + + @field_validator("reviewed_at") + @classmethod + def validate_timezone(cls, value: datetime): + if value.tzinfo is None: + raise ValueError("datetime must have timezone information") + return value + + +NotificationData = Annotated[ + Union[ + AgentRunData, + ZeroBalanceData, + LowBalanceData, + BlockExecutionFailedData, + ContinuousAgentErrorData, + MonthlySummaryData, + WeeklySummaryData, + DailySummaryData, + RefundRequestData, + BaseSummaryData, + ], + Field(discriminator="type"), +] + + +class BaseEventModel(BaseModel): + type: NotificationType + user_id: str + created_at: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc)) + + +class NotificationEventModel(BaseEventModel, Generic[NotificationDataType_co]): + data: NotificationDataType_co + + @property + def strategy(self) -> QueueType: + return NotificationTypeOverride(self.type).strategy + + @field_validator("type", mode="before") + def uppercase_type(cls, v): + if isinstance(v, str): + return v.upper() + return v + + @property + def template(self) -> str: + return NotificationTypeOverride(self.type).template + + +class SummaryParamsEventModel(BaseEventModel, Generic[SummaryParamsType_co]): + data: SummaryParamsType_co + + +def get_notif_data_type( + notification_type: NotificationType, +) -> type[BaseNotificationData]: + return { + NotificationType.AGENT_RUN: AgentRunData, + NotificationType.ZERO_BALANCE: ZeroBalanceData, + NotificationType.LOW_BALANCE: LowBalanceData, + NotificationType.BLOCK_EXECUTION_FAILED: BlockExecutionFailedData, + NotificationType.CONTINUOUS_AGENT_ERROR: ContinuousAgentErrorData, + NotificationType.DAILY_SUMMARY: DailySummaryData, + NotificationType.WEEKLY_SUMMARY: WeeklySummaryData, + NotificationType.MONTHLY_SUMMARY: MonthlySummaryData, + NotificationType.REFUND_REQUEST: RefundRequestData, + NotificationType.REFUND_PROCESSED: RefundRequestData, + NotificationType.AGENT_APPROVED: AgentApprovalData, + NotificationType.AGENT_REJECTED: AgentRejectionData, + }[notification_type] + + +def get_summary_params_type( + notification_type: NotificationType, +) -> type[BaseSummaryParams]: + return { + NotificationType.DAILY_SUMMARY: DailySummaryParams, + NotificationType.WEEKLY_SUMMARY: WeeklySummaryParams, + }[notification_type] + + +class NotificationBatch(BaseModel): + user_id: str + events: list[NotificationEvent] + strategy: QueueType + last_update: datetime = Field(default_factory=lambda: datetime.now(tz=timezone.utc)) + + +class NotificationResult(BaseModel): + success: bool + message: Optional[str] = None + + +class NotificationTypeOverride: + def __init__(self, notification_type: NotificationType): + self.notification_type = notification_type + + @property + def strategy(self) -> QueueType: + BATCHING_RULES = { + # These are batched by the notification service + NotificationType.AGENT_RUN: QueueType.BATCH, + # These are batched by the notification service, but with a backoff strategy + NotificationType.ZERO_BALANCE: QueueType.IMMEDIATE, + NotificationType.LOW_BALANCE: QueueType.IMMEDIATE, + NotificationType.BLOCK_EXECUTION_FAILED: QueueType.BACKOFF, + NotificationType.CONTINUOUS_AGENT_ERROR: QueueType.BACKOFF, + NotificationType.DAILY_SUMMARY: QueueType.SUMMARY, + NotificationType.WEEKLY_SUMMARY: QueueType.SUMMARY, + NotificationType.MONTHLY_SUMMARY: QueueType.SUMMARY, + NotificationType.REFUND_REQUEST: QueueType.ADMIN, + NotificationType.REFUND_PROCESSED: QueueType.ADMIN, + NotificationType.AGENT_APPROVED: QueueType.IMMEDIATE, + NotificationType.AGENT_REJECTED: QueueType.IMMEDIATE, + } + return BATCHING_RULES.get(self.notification_type, QueueType.IMMEDIATE) + + @property + def template(self) -> str: + """Returns template name for this notification type""" + return { + NotificationType.AGENT_RUN: "agent_run.html", + NotificationType.ZERO_BALANCE: "zero_balance.html", + NotificationType.LOW_BALANCE: "low_balance.html", + NotificationType.BLOCK_EXECUTION_FAILED: "block_failed.html", + NotificationType.CONTINUOUS_AGENT_ERROR: "agent_error.html", + NotificationType.DAILY_SUMMARY: "daily_summary.html", + NotificationType.WEEKLY_SUMMARY: "weekly_summary.html", + NotificationType.MONTHLY_SUMMARY: "monthly_summary.html", + NotificationType.REFUND_REQUEST: "refund_request.html", + NotificationType.REFUND_PROCESSED: "refund_processed.html", + NotificationType.AGENT_APPROVED: "agent_approved.html", + NotificationType.AGENT_REJECTED: "agent_rejected.html", + }[self.notification_type] + + @property + def subject(self) -> str: + return { + NotificationType.AGENT_RUN: "Agent Run Report", + NotificationType.ZERO_BALANCE: "You're out of credits!", + NotificationType.LOW_BALANCE: "Low Balance Warning!", + NotificationType.BLOCK_EXECUTION_FAILED: "Uh oh! Block Execution Failed", + NotificationType.CONTINUOUS_AGENT_ERROR: "Shoot! Continuous Agent Error", + NotificationType.DAILY_SUMMARY: "Here's your daily summary!", + NotificationType.WEEKLY_SUMMARY: "Look at all the cool stuff you did last week!", + NotificationType.MONTHLY_SUMMARY: "We did a lot this month!", + NotificationType.REFUND_REQUEST: "[ACTION REQUIRED] You got a ${{data.amount / 100}} refund request from {{data.user_name}}", + NotificationType.REFUND_PROCESSED: "Refund for ${{data.amount / 100}} to {{data.user_name}} has been processed", + NotificationType.AGENT_APPROVED: "🎉 Your agent '{{data.agent_name}}' has been approved!", + NotificationType.AGENT_REJECTED: "Your agent '{{data.agent_name}}' needs some updates", + }[self.notification_type] + + +class NotificationPreferenceDTO(BaseModel): + email: EmailStr = Field(..., description="User's email address") + preferences: dict[NotificationType, bool] = Field( + ..., description="Which notifications the user wants" + ) + daily_limit: int = Field(..., description="Max emails per day") + + +class NotificationPreference(BaseModel): + user_id: str + email: EmailStr + preferences: dict[NotificationType, bool] = Field( + default_factory=dict, description="Which notifications the user wants" + ) + daily_limit: int = 10 # Max emails per day + emails_sent_today: int = 0 + last_reset_date: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc) + ) + + +class UserNotificationEventDTO(BaseModel): + type: NotificationType + data: dict + created_at: datetime + updated_at: datetime + + @staticmethod + def from_db(model: NotificationEvent) -> "UserNotificationEventDTO": + return UserNotificationEventDTO( + type=model.type, + data=dict(model.data), + created_at=model.createdAt, + updated_at=model.updatedAt, + ) + + +class UserNotificationBatchDTO(BaseModel): + user_id: str + type: NotificationType + notifications: list[UserNotificationEventDTO] + created_at: datetime + updated_at: datetime + + @staticmethod + def from_db(model: UserNotificationBatch) -> "UserNotificationBatchDTO": + return UserNotificationBatchDTO( + user_id=model.userId, + type=model.type, + notifications=[ + UserNotificationEventDTO.from_db(notification) + for notification in model.Notifications or [] + ], + created_at=model.createdAt, + updated_at=model.updatedAt, + ) + + +def get_batch_delay(notification_type: NotificationType) -> timedelta: + return { + NotificationType.AGENT_RUN: timedelta(days=1), + NotificationType.ZERO_BALANCE: timedelta(minutes=60), + NotificationType.LOW_BALANCE: timedelta(minutes=60), + NotificationType.BLOCK_EXECUTION_FAILED: timedelta(minutes=60), + NotificationType.CONTINUOUS_AGENT_ERROR: timedelta(minutes=60), + }[notification_type] + + +async def create_or_add_to_user_notification_batch( + user_id: str, + notification_type: NotificationType, + notification_data: NotificationEventModel, +) -> UserNotificationBatchDTO: + try: + if not notification_data.data: + raise ValueError("Notification data must be provided") + + # Serialize the data + json_data: Json = SafeJson(notification_data.data.model_dump()) + + # First try to find existing batch + existing_batch = await UserNotificationBatch.prisma().find_unique( + where={ + "userId_type": { + "userId": user_id, + "type": notification_type, + } + }, + include={"Notifications": True}, + ) + + if not existing_batch: + resp = await UserNotificationBatch.prisma().create( + data=UserNotificationBatchCreateInput( + userId=user_id, + type=notification_type, + Notifications={ + "create": [ + NotificationEventCreateInput( + type=notification_type, + data=json_data, + ) + ] + }, + ), + include={"Notifications": True}, + ) + return UserNotificationBatchDTO.from_db(resp) + else: + resp = await UserNotificationBatch.prisma().update( + where={"id": existing_batch.id}, + data={ + "Notifications": { + "create": [ + NotificationEventCreateInput( + type=notification_type, + data=json_data, + ) + ] + } + }, + include={"Notifications": True}, + ) + if not resp: + raise DatabaseError( + f"Failed to add notification event to existing batch {existing_batch.id}" + ) + return UserNotificationBatchDTO.from_db(resp) + except Exception as e: + raise DatabaseError( + f"Failed to create or add to notification batch for user {user_id} and type {notification_type}: {e}" + ) from e + + +async def get_user_notification_oldest_message_in_batch( + user_id: str, + notification_type: NotificationType, +) -> UserNotificationEventDTO | None: + try: + batch = await UserNotificationBatch.prisma().find_first( + where={"userId": user_id, "type": notification_type}, + include={"Notifications": True}, + ) + if not batch: + return None + if not batch.Notifications: + return None + sorted_notifications = sorted(batch.Notifications, key=lambda x: x.createdAt) + + return ( + UserNotificationEventDTO.from_db(sorted_notifications[0]) + if sorted_notifications + else None + ) + except Exception as e: + raise DatabaseError( + f"Failed to get user notification last message in batch for user {user_id} and type {notification_type}: {e}" + ) from e + + +async def empty_user_notification_batch( + user_id: str, notification_type: NotificationType +) -> None: + try: + async with transaction() as tx: + await tx.notificationevent.delete_many( + where={ + "UserNotificationBatch": { + "is": {"userId": user_id, "type": notification_type} + } + } + ) + + await tx.usernotificationbatch.delete_many( + where=UserNotificationBatchWhereInput( + userId=user_id, + type=notification_type, + ) + ) + except Exception as e: + raise DatabaseError( + f"Failed to empty user notification batch for user {user_id} and type {notification_type}: {e}" + ) from e + + +async def get_user_notification_batch( + user_id: str, + notification_type: NotificationType, +) -> UserNotificationBatchDTO | None: + try: + batch = await UserNotificationBatch.prisma().find_first( + where={"userId": user_id, "type": notification_type}, + include={"Notifications": True}, + ) + return UserNotificationBatchDTO.from_db(batch) if batch else None + except Exception as e: + raise DatabaseError( + f"Failed to get user notification batch for user {user_id} and type {notification_type}: {e}" + ) from e + + +async def get_all_batches_by_type( + notification_type: NotificationType, +) -> list[UserNotificationBatchDTO]: + try: + batches = await UserNotificationBatch.prisma().find_many( + where={ + "type": notification_type, + "Notifications": { + "some": {} # Only return batches with at least one notification + }, + }, + include={"Notifications": True}, + ) + return [UserNotificationBatchDTO.from_db(batch) for batch in batches] + except Exception as e: + raise DatabaseError( + f"Failed to get all batches by type {notification_type}: {e}" + ) from e diff --git a/autogpt_platform/backend/backend/data/notifications_test.py b/autogpt_platform/backend/backend/data/notifications_test.py new file mode 100644 index 000000000000..bdbf64401eb1 --- /dev/null +++ b/autogpt_platform/backend/backend/data/notifications_test.py @@ -0,0 +1,151 @@ +"""Tests for notification data models.""" + +from datetime import datetime, timezone + +import pytest +from pydantic import ValidationError + +from backend.data.notifications import AgentApprovalData, AgentRejectionData + + +class TestAgentApprovalData: + """Test cases for AgentApprovalData model.""" + + def test_valid_agent_approval_data(self): + """Test creating valid AgentApprovalData.""" + data = AgentApprovalData( + agent_name="Test Agent", + agent_id="test-agent-123", + agent_version=1, + reviewer_name="John Doe", + reviewer_email="john@example.com", + comments="Great agent, approved!", + reviewed_at=datetime.now(timezone.utc), + store_url="https://app.autogpt.com/store/test-agent-123", + ) + + assert data.agent_name == "Test Agent" + assert data.agent_id == "test-agent-123" + assert data.agent_version == 1 + assert data.reviewer_name == "John Doe" + assert data.reviewer_email == "john@example.com" + assert data.comments == "Great agent, approved!" + assert data.store_url == "https://app.autogpt.com/store/test-agent-123" + assert data.reviewed_at.tzinfo is not None + + def test_agent_approval_data_without_timezone_raises_error(self): + """Test that AgentApprovalData raises error without timezone.""" + with pytest.raises( + ValidationError, match="datetime must have timezone information" + ): + AgentApprovalData( + agent_name="Test Agent", + agent_id="test-agent-123", + agent_version=1, + reviewer_name="John Doe", + reviewer_email="john@example.com", + comments="Great agent, approved!", + reviewed_at=datetime.now(), # No timezone + store_url="https://app.autogpt.com/store/test-agent-123", + ) + + def test_agent_approval_data_with_empty_comments(self): + """Test AgentApprovalData with empty comments.""" + data = AgentApprovalData( + agent_name="Test Agent", + agent_id="test-agent-123", + agent_version=1, + reviewer_name="John Doe", + reviewer_email="john@example.com", + comments="", # Empty comments + reviewed_at=datetime.now(timezone.utc), + store_url="https://app.autogpt.com/store/test-agent-123", + ) + + assert data.comments == "" + + +class TestAgentRejectionData: + """Test cases for AgentRejectionData model.""" + + def test_valid_agent_rejection_data(self): + """Test creating valid AgentRejectionData.""" + data = AgentRejectionData( + agent_name="Test Agent", + agent_id="test-agent-123", + agent_version=1, + reviewer_name="Jane Doe", + reviewer_email="jane@example.com", + comments="Please fix the security issues before resubmitting.", + reviewed_at=datetime.now(timezone.utc), + resubmit_url="https://app.autogpt.com/build/test-agent-123", + ) + + assert data.agent_name == "Test Agent" + assert data.agent_id == "test-agent-123" + assert data.agent_version == 1 + assert data.reviewer_name == "Jane Doe" + assert data.reviewer_email == "jane@example.com" + assert data.comments == "Please fix the security issues before resubmitting." + assert data.resubmit_url == "https://app.autogpt.com/build/test-agent-123" + assert data.reviewed_at.tzinfo is not None + + def test_agent_rejection_data_without_timezone_raises_error(self): + """Test that AgentRejectionData raises error without timezone.""" + with pytest.raises( + ValidationError, match="datetime must have timezone information" + ): + AgentRejectionData( + agent_name="Test Agent", + agent_id="test-agent-123", + agent_version=1, + reviewer_name="Jane Doe", + reviewer_email="jane@example.com", + comments="Please fix the security issues.", + reviewed_at=datetime.now(), # No timezone + resubmit_url="https://app.autogpt.com/build/test-agent-123", + ) + + def test_agent_rejection_data_with_long_comments(self): + """Test AgentRejectionData with long comments.""" + long_comment = "A" * 1000 # Very long comment + data = AgentRejectionData( + agent_name="Test Agent", + agent_id="test-agent-123", + agent_version=1, + reviewer_name="Jane Doe", + reviewer_email="jane@example.com", + comments=long_comment, + reviewed_at=datetime.now(timezone.utc), + resubmit_url="https://app.autogpt.com/build/test-agent-123", + ) + + assert data.comments == long_comment + + def test_model_serialization(self): + """Test that models can be serialized and deserialized.""" + original_data = AgentRejectionData( + agent_name="Test Agent", + agent_id="test-agent-123", + agent_version=1, + reviewer_name="Jane Doe", + reviewer_email="jane@example.com", + comments="Please fix the issues.", + reviewed_at=datetime.now(timezone.utc), + resubmit_url="https://app.autogpt.com/build/test-agent-123", + ) + + # Serialize to dict + data_dict = original_data.model_dump() + + # Deserialize back + restored_data = AgentRejectionData.model_validate(data_dict) + + assert restored_data.agent_name == original_data.agent_name + assert restored_data.agent_id == original_data.agent_id + assert restored_data.agent_version == original_data.agent_version + assert restored_data.reviewer_name == original_data.reviewer_name + assert restored_data.reviewer_email == original_data.reviewer_email + assert restored_data.comments == original_data.comments + assert restored_data.reviewed_at == original_data.reviewed_at + assert restored_data.resubmit_url == original_data.resubmit_url diff --git a/autogpt_platform/backend/backend/data/onboarding.py b/autogpt_platform/backend/backend/data/onboarding.py new file mode 100644 index 000000000000..817122f90ae3 --- /dev/null +++ b/autogpt_platform/backend/backend/data/onboarding.py @@ -0,0 +1,340 @@ +import re +from typing import Any, Optional + +import prisma +import pydantic +from prisma.enums import OnboardingStep +from prisma.models import UserOnboarding +from prisma.types import UserOnboardingCreateInput, UserOnboardingUpdateInput + +from backend.data.block import get_blocks +from backend.data.credit import get_user_credit_model +from backend.data.graph import GraphModel +from backend.data.model import CredentialsMetaInput +from backend.server.v2.store.model import StoreAgentDetails +from backend.util.json import SafeJson + +# Mapping from user reason id to categories to search for when choosing agent to show +REASON_MAPPING: dict[str, list[str]] = { + "content_marketing": ["writing", "marketing", "creative"], + "business_workflow_automation": ["business", "productivity"], + "data_research": ["data", "research"], + "ai_innovation": ["development", "research"], + "personal_productivity": ["personal", "productivity"], +} +POINTS_AGENT_COUNT = 50 # Number of agents to calculate points for +MIN_AGENT_COUNT = 2 # Minimum number of marketplace agents to enable onboarding + +user_credit = get_user_credit_model() + + +class UserOnboardingUpdate(pydantic.BaseModel): + completedSteps: Optional[list[OnboardingStep]] = None + notificationDot: Optional[bool] = None + notified: Optional[list[OnboardingStep]] = None + usageReason: Optional[str] = None + integrations: Optional[list[str]] = None + otherIntegrations: Optional[str] = None + selectedStoreListingVersionId: Optional[str] = None + agentInput: Optional[dict[str, Any]] = None + onboardingAgentExecutionId: Optional[str] = None + agentRuns: Optional[int] = None + + +async def get_user_onboarding(user_id: str): + return await UserOnboarding.prisma().upsert( + where={"userId": user_id}, + data={ + "create": UserOnboardingCreateInput(userId=user_id), + "update": {}, + }, + ) + + +async def update_user_onboarding(user_id: str, data: UserOnboardingUpdate): + update: UserOnboardingUpdateInput = {} + if data.completedSteps is not None: + update["completedSteps"] = list(set(data.completedSteps)) + for step in ( + OnboardingStep.AGENT_NEW_RUN, + OnboardingStep.RUN_AGENTS, + OnboardingStep.MARKETPLACE_ADD_AGENT, + OnboardingStep.MARKETPLACE_RUN_AGENT, + OnboardingStep.BUILDER_SAVE_AGENT, + OnboardingStep.BUILDER_RUN_AGENT, + ): + if step in data.completedSteps: + await reward_user(user_id, step) + if data.notificationDot is not None: + update["notificationDot"] = data.notificationDot + if data.notified is not None: + update["notified"] = list(set(data.notified)) + if data.usageReason is not None: + update["usageReason"] = data.usageReason + if data.integrations is not None: + update["integrations"] = data.integrations + if data.otherIntegrations is not None: + update["otherIntegrations"] = data.otherIntegrations + if data.selectedStoreListingVersionId is not None: + update["selectedStoreListingVersionId"] = data.selectedStoreListingVersionId + if data.agentInput is not None: + update["agentInput"] = SafeJson(data.agentInput) + if data.onboardingAgentExecutionId is not None: + update["onboardingAgentExecutionId"] = data.onboardingAgentExecutionId + if data.agentRuns is not None: + update["agentRuns"] = data.agentRuns + + return await UserOnboarding.prisma().upsert( + where={"userId": user_id}, + data={ + "create": {"userId": user_id, **update}, + "update": update, + }, + ) + + +async def reward_user(user_id: str, step: OnboardingStep): + reward = 0 + match step: + # Reward user when they clicked New Run during onboarding + # This is because they need credits before scheduling a run (next step) + # This is seen as a reward for the GET_RESULTS step in the wallet + case OnboardingStep.AGENT_NEW_RUN: + reward = 300 + case OnboardingStep.RUN_AGENTS: + reward = 300 + case OnboardingStep.MARKETPLACE_ADD_AGENT: + reward = 100 + case OnboardingStep.MARKETPLACE_RUN_AGENT: + reward = 100 + case OnboardingStep.BUILDER_SAVE_AGENT: + reward = 100 + case OnboardingStep.BUILDER_RUN_AGENT: + reward = 100 + + if reward == 0: + return + + onboarding = await get_user_onboarding(user_id) + + # Skip if already rewarded + if step in onboarding.rewardedFor: + return + + onboarding.rewardedFor.append(step) + await user_credit.onboarding_reward(user_id, reward, step) + await UserOnboarding.prisma().update( + where={"userId": user_id}, + data={ + "completedSteps": list(set(onboarding.completedSteps + [step])), + "rewardedFor": onboarding.rewardedFor, + }, + ) + + +def clean_and_split(text: str) -> list[str]: + """ + Removes all special characters from a string, truncates it to 100 characters, + and splits it by whitespace and commas. + + Args: + text (str): The input string. + + Returns: + list[str]: A list of cleaned words. + """ + # Remove all special characters (keep only alphanumeric and whitespace) + cleaned_text = re.sub(r"[^a-zA-Z0-9\s,]", "", text.strip()[:100]) + + # Split by whitespace and commas + words = re.split(r"[\s,]+", cleaned_text) + + # Remove empty strings from the list + words = [word.lower() for word in words if word] + + return words + + +def calculate_points( + agent, categories: list[str], custom: list[str], integrations: list[str] +) -> int: + """ + Calculates the total points for an agent based on the specified criteria. + + Args: + agent: The agent object. + categories (list[str]): List of categories to match. + words (list[str]): List of words to match in the description. + + Returns: + int: Total points for the agent. + """ + points = 0 + + # 1. Category Matches + matched_categories = sum( + 1 for category in categories if category in agent.categories + ) + points += matched_categories * 100 + + # 2. Description Word Matches + description_words = agent.description.split() # Split description into words + matched_words = sum(1 for word in custom if word in description_words) + points += matched_words * 100 + + matched_words = sum(1 for word in integrations if word in description_words) + points += matched_words * 50 + + # 3. Featured Bonus + if agent.featured: + points += 50 + + # 4. Rating Bonus + points += agent.rating * 10 + + # 5. Runs Bonus + runs_points = min(agent.runs / 1000 * 100, 100) # Cap at 100 points + points += runs_points + + return int(points) + + +def get_credentials_blocks() -> dict[str, str]: + # Returns a dictionary of block id to credentials field name + creds: dict[str, str] = {} + blocks = get_blocks() + for id, block in blocks.items(): + for field_name, field_info in block().input_schema.model_fields.items(): + if field_info.annotation == CredentialsMetaInput: + creds[id] = field_name + return creds + + +CREDENTIALS_FIELDS: dict[str, str] = get_credentials_blocks() + + +async def get_recommended_agents(user_id: str) -> list[StoreAgentDetails]: + user_onboarding = await get_user_onboarding(user_id) + categories = REASON_MAPPING.get(user_onboarding.usageReason or "", []) + + where_clause: dict[str, Any] = {} + + custom = clean_and_split((user_onboarding.usageReason or "").lower()) + + if categories: + where_clause["OR"] = [ + {"categories": {"has": category}} for category in categories + ] + else: + where_clause["OR"] = [ + {"description": {"contains": word, "mode": "insensitive"}} + for word in custom + ] + + where_clause["OR"] += [ + {"description": {"contains": word, "mode": "insensitive"}} + for word in user_onboarding.integrations + ] + + storeAgents = await prisma.models.StoreAgent.prisma().find_many( + where=prisma.types.StoreAgentWhereInput(**where_clause), + order=[ + {"featured": "desc"}, + {"runs": "desc"}, + {"rating": "desc"}, + ], + take=100, + ) + + agentListings = await prisma.models.StoreListingVersion.prisma().find_many( + where={ + "id": {"in": [agent.storeListingVersionId for agent in storeAgents]}, + }, + include={"AgentGraph": True}, + ) + + for listing in agentListings: + agent = listing.AgentGraph + if agent is None: + continue + graph = GraphModel.from_db(agent) + # Remove agents with empty input schema + if not graph.input_schema: + storeAgents = [ + a for a in storeAgents if a.storeListingVersionId != listing.id + ] + continue + + # Remove agents with empty credentials + # Get nodes from this agent that have credentials + nodes = await prisma.models.AgentNode.prisma().find_many( + where={ + "agentGraphId": agent.id, + "agentBlockId": {"in": list(CREDENTIALS_FIELDS.keys())}, + }, + ) + for node in nodes: + block_id = node.agentBlockId + field_name = CREDENTIALS_FIELDS[block_id] + # If there are no credentials or they are empty, remove the agent + # FIXME ignores default values + if ( + field_name not in node.constantInput + or node.constantInput[field_name] is None + ): + storeAgents = [ + a for a in storeAgents if a.storeListingVersionId != listing.id + ] + break + + # If there are less than 2 agents, add more agents to the list + if len(storeAgents) < 2: + storeAgents += await prisma.models.StoreAgent.prisma().find_many( + where={ + "listing_id": {"not_in": [agent.listing_id for agent in storeAgents]}, + }, + order=[ + {"featured": "desc"}, + {"runs": "desc"}, + {"rating": "desc"}, + ], + take=2 - len(storeAgents), + ) + + # Calculate points for the first X agents and choose the top 2 + agent_points = [] + for agent in storeAgents[:POINTS_AGENT_COUNT]: + points = calculate_points( + agent, categories, custom, user_onboarding.integrations + ) + agent_points.append((agent, points)) + + agent_points.sort(key=lambda x: x[1], reverse=True) + recommended_agents = [agent for agent, _ in agent_points[:2]] + + return [ + StoreAgentDetails( + store_listing_version_id=agent.storeListingVersionId, + slug=agent.slug, + agent_name=agent.agent_name, + agent_video=agent.agent_video or "", + agent_image=agent.agent_image, + creator=agent.creator_username, + creator_avatar=agent.creator_avatar, + sub_heading=agent.sub_heading, + description=agent.description, + categories=agent.categories, + runs=agent.runs, + rating=agent.rating, + versions=agent.versions, + last_updated=agent.updated_at, + ) + for agent in recommended_agents + ] + + +async def onboarding_enabled() -> bool: + count = await prisma.models.StoreAgent.prisma().count(take=MIN_AGENT_COUNT + 1) + + # Onboading is enabled if there are at least 2 agents in the store + return count >= MIN_AGENT_COUNT diff --git a/autogpt_platform/backend/backend/data/queue.py b/autogpt_platform/backend/backend/data/queue.py deleted file mode 100644 index 8fbdb03cd6f0..000000000000 --- a/autogpt_platform/backend/backend/data/queue.py +++ /dev/null @@ -1,122 +0,0 @@ -import asyncio -import json -import logging -from abc import ABC, abstractmethod -from datetime import datetime -from typing import Any, AsyncGenerator, Generator, Generic, Optional, TypeVar - -from pydantic import BaseModel -from redis.asyncio.client import PubSub as AsyncPubSub -from redis.client import PubSub - -from backend.data import redis - -logger = logging.getLogger(__name__) - - -class DateTimeEncoder(json.JSONEncoder): - def default(self, o): - if isinstance(o, datetime): - return o.isoformat() - return super().default(o) - - -M = TypeVar("M", bound=BaseModel) - - -class BaseRedisEventBus(Generic[M], ABC): - Model: type[M] - - @property - @abstractmethod - def event_bus_name(self) -> str: - pass - - def _serialize_message(self, item: M, channel_key: str) -> tuple[str, str]: - message = json.dumps(item.model_dump(), cls=DateTimeEncoder) - channel_name = f"{self.event_bus_name}/{channel_key}" - logger.info(f"[{channel_name}] Publishing an event to Redis {message}") - return message, channel_name - - def _deserialize_message(self, msg: Any, channel_key: str) -> M | None: - message_type = "pmessage" if "*" in channel_key else "message" - if msg["type"] != message_type: - return None - try: - data = json.loads(msg["data"]) - logger.info(f"Consuming an event from Redis {data}") - return self.Model(**data) - except Exception as e: - logger.error(f"Failed to parse event result from Redis {msg} {e}") - - def _get_pubsub_channel( - self, connection: redis.Redis | redis.AsyncRedis, channel_key: str - ) -> tuple[PubSub | AsyncPubSub, str]: - full_channel_name = f"{self.event_bus_name}/{channel_key}" - pubsub = connection.pubsub() - return pubsub, full_channel_name - - -class RedisEventBus(BaseRedisEventBus[M], ABC): - Model: type[M] - - @property - def connection(self) -> redis.Redis: - return redis.get_redis() - - def publish_event(self, event: M, channel_key: str): - message, full_channel_name = self._serialize_message(event, channel_key) - self.connection.publish(full_channel_name, message) - - def listen_events(self, channel_key: str) -> Generator[M, None, None]: - pubsub, full_channel_name = self._get_pubsub_channel( - self.connection, channel_key - ) - assert isinstance(pubsub, PubSub) - - if "*" in channel_key: - pubsub.psubscribe(full_channel_name) - else: - pubsub.subscribe(full_channel_name) - - for message in pubsub.listen(): - if event := self._deserialize_message(message, channel_key): - yield event - - -class AsyncRedisEventBus(BaseRedisEventBus[M], ABC): - Model: type[M] - - @property - async def connection(self) -> redis.AsyncRedis: - return await redis.get_redis_async() - - async def publish_event(self, event: M, channel_key: str): - message, full_channel_name = self._serialize_message(event, channel_key) - connection = await self.connection - await connection.publish(full_channel_name, message) - - async def listen_events(self, channel_key: str) -> AsyncGenerator[M, None]: - pubsub, full_channel_name = self._get_pubsub_channel( - await self.connection, channel_key - ) - assert isinstance(pubsub, AsyncPubSub) - - if "*" in channel_key: - await pubsub.psubscribe(full_channel_name) - else: - await pubsub.subscribe(full_channel_name) - - async for message in pubsub.listen(): - if event := self._deserialize_message(message, channel_key): - yield event - - async def wait_for_event( - self, channel_key: str, timeout: Optional[float] = None - ) -> M | None: - try: - return await asyncio.wait_for( - anext(aiter(self.listen_events(channel_key))), timeout - ) - except TimeoutError: - return None diff --git a/autogpt_platform/backend/backend/data/rabbitmq.py b/autogpt_platform/backend/backend/data/rabbitmq.py new file mode 100644 index 000000000000..bdf2090083d8 --- /dev/null +++ b/autogpt_platform/backend/backend/data/rabbitmq.py @@ -0,0 +1,330 @@ +import logging +from abc import ABC, abstractmethod +from enum import Enum +from typing import Awaitable, Optional + +import aio_pika +import pika +import pika.adapters.blocking_connection +from pika.spec import BasicProperties +from pydantic import BaseModel + +from backend.util.retry import conn_retry, func_retry +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) + +# RabbitMQ Connection Constants +# These constants solve specific connection stability issues observed in production + +# BLOCKED_CONNECTION_TIMEOUT (300s = 5 minutes) +# Problem: Connection can hang indefinitely if RabbitMQ server is overloaded +# Solution: Timeout and reconnect if connection is blocked for too long +# Use case: Network issues or server resource constraints +BLOCKED_CONNECTION_TIMEOUT = 300 + +# SOCKET_TIMEOUT (30s) +# Problem: Network operations can hang indefinitely on poor connections +# Solution: Fail fast on socket operations to enable quick reconnection +# Use case: Network latency, packet loss, or connectivity issues +SOCKET_TIMEOUT = 30 + +# CONNECTION_ATTEMPTS (5 attempts) +# Problem: Temporary network issues cause permanent connection failures +# Solution: More retry attempts for better resilience during long executions +# Use case: Transient network issues during service startup or long-running operations +CONNECTION_ATTEMPTS = 5 + +# RETRY_DELAY (1 second) +# Problem: Immediate reconnection attempts can overwhelm the server +# Solution: Quick retry for faster recovery while still being respectful +# Use case: Faster reconnection for long-running executions that need to resume quickly +RETRY_DELAY = 1 + + +class ExchangeType(str, Enum): + DIRECT = "direct" + FANOUT = "fanout" + TOPIC = "topic" + HEADERS = "headers" + + +class Exchange(BaseModel): + name: str + type: ExchangeType + durable: bool = True + auto_delete: bool = False + + +class Queue(BaseModel): + name: str + durable: bool = True + auto_delete: bool = False + # Optional exchange binding configuration + exchange: Optional[Exchange] = None + routing_key: Optional[str] = None + arguments: Optional[dict] = None + + +class RabbitMQConfig(BaseModel): + """Configuration for a RabbitMQ service instance""" + + vhost: str = "/" + exchanges: list[Exchange] + queues: list[Queue] + + +class RabbitMQBase(ABC): + """Base class for RabbitMQ connections with shared configuration""" + + def __init__(self, config: RabbitMQConfig): + settings = Settings() + self.host = settings.config.rabbitmq_host + self.port = settings.config.rabbitmq_port + self.username = settings.secrets.rabbitmq_default_user + self.password = settings.secrets.rabbitmq_default_pass + self.config = config + + self._connection = None + self._channel = None + + @property + def is_connected(self) -> bool: + """Check if we have a valid connection""" + return bool(self._connection) + + @property + def is_ready(self) -> bool: + """Check if we have a valid channel""" + return bool(self.is_connected and self._channel) + + @abstractmethod + def connect(self) -> None | Awaitable[None]: + """Establish connection to RabbitMQ""" + pass + + @abstractmethod + def disconnect(self) -> None | Awaitable[None]: + """Close connection to RabbitMQ""" + pass + + @abstractmethod + def declare_infrastructure(self) -> None | Awaitable[None]: + """Declare exchanges and queues for this service""" + pass + + +class SyncRabbitMQ(RabbitMQBase): + """Synchronous RabbitMQ client""" + + @property + def is_connected(self) -> bool: + return bool(self._connection and self._connection.is_open) + + @property + def is_ready(self) -> bool: + return bool(self.is_connected and self._channel and self._channel.is_open) + + @conn_retry("RabbitMQ", "Acquiring connection") + def connect(self) -> None: + if self.is_connected: + return + + credentials = pika.PlainCredentials(self.username, self.password) + parameters = pika.ConnectionParameters( + host=self.host, + port=self.port, + virtual_host=self.config.vhost, + credentials=credentials, + blocked_connection_timeout=BLOCKED_CONNECTION_TIMEOUT, + socket_timeout=SOCKET_TIMEOUT, + connection_attempts=CONNECTION_ATTEMPTS, + retry_delay=RETRY_DELAY, + heartbeat=300, # 5 minute timeout (heartbeats sent every 2.5 min) + ) + + self._connection = pika.BlockingConnection(parameters) + self._channel = self._connection.channel() + self._channel.basic_qos(prefetch_count=1) + + self.declare_infrastructure() + + def disconnect(self) -> None: + if self._channel: + if self._channel.is_open: + self._channel.close() + self._channel = None + if self._connection: + if self._connection.is_open: + self._connection.close() + self._connection = None + + def declare_infrastructure(self) -> None: + """Declare exchanges and queues for this service""" + if not self.is_ready: + self.connect() + + if self._channel is None: + raise RuntimeError("Channel should be established after connect") + + # Declare exchanges + for exchange in self.config.exchanges: + self._channel.exchange_declare( + exchange=exchange.name, + exchange_type=exchange.type.value, + durable=exchange.durable, + auto_delete=exchange.auto_delete, + ) + + # Declare queues and bind them to exchanges + for queue in self.config.queues: + self._channel.queue_declare( + queue=queue.name, + durable=queue.durable, + auto_delete=queue.auto_delete, + arguments=queue.arguments, + ) + if queue.exchange: + self._channel.queue_bind( + queue=queue.name, + exchange=queue.exchange.name, + routing_key=queue.routing_key or queue.name, + ) + + @func_retry + def publish_message( + self, + routing_key: str, + message: str, + exchange: Optional[Exchange] = None, + properties: Optional[BasicProperties] = None, + mandatory: bool = True, + ) -> None: + if not self.is_ready: + self.connect() + + if self._channel is None: + raise RuntimeError("Channel should be established after connect") + + self._channel.basic_publish( + exchange=exchange.name if exchange else "", + routing_key=routing_key, + body=message.encode(), + properties=properties or BasicProperties(delivery_mode=2), + mandatory=mandatory, + ) + + def get_channel(self) -> pika.adapters.blocking_connection.BlockingChannel: + if not self.is_ready: + self.connect() + if self._channel is None: + raise RuntimeError("Channel should be established after connect") + return self._channel + + +class AsyncRabbitMQ(RabbitMQBase): + """Asynchronous RabbitMQ client""" + + @property + def is_connected(self) -> bool: + return bool(self._connection and not self._connection.is_closed) + + @property + def is_ready(self) -> bool: + return bool(self.is_connected and self._channel and not self._channel.is_closed) + + @conn_retry("AsyncRabbitMQ", "Acquiring async connection") + async def connect(self): + if self.is_connected: + return + + self._connection = await aio_pika.connect_robust( + host=self.host, + port=self.port, + login=self.username, + password=self.password, + virtualhost=self.config.vhost.lstrip("/"), + blocked_connection_timeout=BLOCKED_CONNECTION_TIMEOUT, + heartbeat=300, # 5 minute timeout (heartbeats sent every 2.5 min) + ) + self._channel = await self._connection.channel() + await self._channel.set_qos(prefetch_count=1) + + await self.declare_infrastructure() + + async def disconnect(self): + if self._channel: + await self._channel.close() + self._channel = None + if self._connection: + await self._connection.close() + self._connection = None + + async def declare_infrastructure(self): + """Declare exchanges and queues for this service""" + if not self.is_ready: + await self.connect() + + if self._channel is None: + raise RuntimeError("Channel should be established after connect") + + # Declare exchanges + for exchange in self.config.exchanges: + await self._channel.declare_exchange( + name=exchange.name, + type=exchange.type.value, + durable=exchange.durable, + auto_delete=exchange.auto_delete, + ) + + # Declare queues and bind them to exchanges + for queue in self.config.queues: + queue_obj = await self._channel.declare_queue( + name=queue.name, + durable=queue.durable, + auto_delete=queue.auto_delete, + arguments=queue.arguments, + ) + if queue.exchange: + exchange = await self._channel.get_exchange(queue.exchange.name) + await queue_obj.bind( + exchange, routing_key=queue.routing_key or queue.name + ) + + @func_retry + async def publish_message( + self, + routing_key: str, + message: str, + exchange: Optional[Exchange] = None, + persistent: bool = True, + ) -> None: + if not self.is_ready: + await self.connect() + + if self._channel is None: + raise RuntimeError("Channel should be established after connect") + + if exchange: + exchange_obj = await self._channel.get_exchange(exchange.name) + else: + exchange_obj = self._channel.default_exchange + + await exchange_obj.publish( + aio_pika.Message( + body=message.encode(), + delivery_mode=( + aio_pika.DeliveryMode.PERSISTENT + if persistent + else aio_pika.DeliveryMode.NOT_PERSISTENT + ), + ), + routing_key=routing_key, + ) + + async def get_channel(self) -> aio_pika.abc.AbstractChannel: + if not self.is_ready: + await self.connect() + if self._channel is None: + raise RuntimeError("Channel should be established after connect") + return self._channel diff --git a/autogpt_platform/backend/backend/data/redis.py b/autogpt_platform/backend/backend/data/redis.py deleted file mode 100644 index 36410fe29cb2..000000000000 --- a/autogpt_platform/backend/backend/data/redis.py +++ /dev/null @@ -1,84 +0,0 @@ -import logging -import os - -from dotenv import load_dotenv -from redis import Redis -from redis.asyncio import Redis as AsyncRedis - -from backend.util.retry import conn_retry - -load_dotenv() - -HOST = os.getenv("REDIS_HOST", "localhost") -PORT = int(os.getenv("REDIS_PORT", "6379")) -PASSWORD = os.getenv("REDIS_PASSWORD", "password") - -logger = logging.getLogger(__name__) -connection: Redis | None = None -connection_async: AsyncRedis | None = None - - -@conn_retry("Redis", "Acquiring connection") -def connect() -> Redis: - global connection - if connection: - return connection - - c = Redis( - host=HOST, - port=PORT, - password=PASSWORD, - decode_responses=True, - ) - c.ping() - connection = c - return connection - - -@conn_retry("Redis", "Releasing connection") -def disconnect(): - global connection - if connection: - connection.close() - connection = None - - -def get_redis(auto_connect: bool = True) -> Redis: - if connection: - return connection - if auto_connect: - return connect() - raise RuntimeError("Redis connection is not established") - - -@conn_retry("AsyncRedis", "Acquiring connection") -async def connect_async() -> AsyncRedis: - global connection_async - if connection_async: - return connection_async - - c = AsyncRedis( - host=HOST, - port=PORT, - password=PASSWORD, - decode_responses=True, - ) - await c.ping() - connection_async = c - return connection_async - - -@conn_retry("AsyncRedis", "Releasing connection") -async def disconnect_async(): - global connection_async - if connection_async: - await connection_async.close() - connection_async = None - - -async def get_redis_async(auto_connect: bool = True) -> AsyncRedis: - if connection_async: - return connection_async - if auto_connect: - return await connect_async() - raise RuntimeError("AsyncRedis connection is not established") diff --git a/autogpt_platform/backend/backend/data/redis_client.py b/autogpt_platform/backend/backend/data/redis_client.py new file mode 100644 index 000000000000..c6225131f2ea --- /dev/null +++ b/autogpt_platform/backend/backend/data/redis_client.py @@ -0,0 +1,63 @@ +import logging +import os +from functools import cache + +from autogpt_libs.utils.cache import thread_cached +from dotenv import load_dotenv +from redis import Redis +from redis.asyncio import Redis as AsyncRedis + +from backend.util.retry import conn_retry + +load_dotenv() + +HOST = os.getenv("REDIS_HOST", "localhost") +PORT = int(os.getenv("REDIS_PORT", "6379")) +PASSWORD = os.getenv("REDIS_PASSWORD", "password") + +logger = logging.getLogger(__name__) + + +@conn_retry("Redis", "Acquiring connection") +def connect() -> Redis: + c = Redis( + host=HOST, + port=PORT, + password=PASSWORD, + decode_responses=True, + ) + c.ping() + return c + + +@conn_retry("Redis", "Releasing connection") +def disconnect(): + get_redis().close() + + +@cache +def get_redis() -> Redis: + return connect() + + +@conn_retry("AsyncRedis", "Acquiring connection") +async def connect_async() -> AsyncRedis: + c = AsyncRedis( + host=HOST, + port=PORT, + password=PASSWORD, + decode_responses=True, + ) + await c.ping() + return c + + +@conn_retry("AsyncRedis", "Releasing connection") +async def disconnect_async(): + c = await get_redis_async() + await c.close() + + +@thread_cached +async def get_redis_async() -> AsyncRedis: + return await connect_async() diff --git a/autogpt_platform/backend/backend/data/user.py b/autogpt_platform/backend/backend/data/user.py index 8602d0f3b136..3b1dd296db11 100644 --- a/autogpt_platform/backend/backend/data/user.py +++ b/autogpt_platform/backend/backend/data/user.py @@ -1,75 +1,101 @@ +import base64 +import hashlib +import hmac import logging +from datetime import datetime, timedelta from typing import Optional, cast +from urllib.parse import quote_plus from autogpt_libs.auth.models import DEFAULT_USER_ID from fastapi import HTTPException -from prisma import Json -from prisma.models import User +from prisma.enums import NotificationType +from prisma.models import User as PrismaUser +from prisma.types import JsonFilter, UserCreateInput, UserUpdateInput from backend.data.db import prisma -from backend.data.model import UserIntegrations, UserMetadata, UserMetadataRaw +from backend.data.model import User, UserIntegrations, UserMetadata +from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO +from backend.server.v2.store.exceptions import DatabaseError from backend.util.encryption import JSONCryptor +from backend.util.json import SafeJson +from backend.util.settings import Settings logger = logging.getLogger(__name__) +settings = Settings() async def get_or_create_user(user_data: dict) -> User: - user_id = user_data.get("sub") - if not user_id: - raise HTTPException(status_code=401, detail="User ID not found in token") + try: + user_id = user_data.get("sub") + if not user_id: + raise HTTPException(status_code=401, detail="User ID not found in token") - user_email = user_data.get("email") - if not user_email: - raise HTTPException(status_code=401, detail="Email not found in token") + user_email = user_data.get("email") + if not user_email: + raise HTTPException(status_code=401, detail="Email not found in token") + user = await prisma.user.find_unique(where={"id": user_id}) + if not user: + user = await prisma.user.create( + data=UserCreateInput( + id=user_id, + email=user_email, + name=user_data.get("user_metadata", {}).get("name"), + ) + ) + + return User.from_db(user) + except Exception as e: + raise DatabaseError(f"Failed to get or create user {user_data}: {e}") from e + + +async def get_user_by_id(user_id: str) -> User: user = await prisma.user.find_unique(where={"id": user_id}) if not user: - user = await prisma.user.create( - data={ - "id": user_id, - "email": user_email, - "name": user_data.get("user_metadata", {}).get("name"), - } - ) - return User.model_validate(user) + raise ValueError(f"User not found with ID: {user_id}") + return User.from_db(user) -async def get_user_by_id(user_id: str) -> Optional[User]: - user = await prisma.user.find_unique(where={"id": user_id}) - return User.model_validate(user) if user else None +async def get_user_email_by_id(user_id: str) -> Optional[str]: + try: + user = await prisma.user.find_unique(where={"id": user_id}) + return user.email if user else None + except Exception as e: + raise DatabaseError(f"Failed to get user email for user {user_id}: {e}") from e + + +async def get_user_by_email(email: str) -> Optional[User]: + try: + user = await prisma.user.find_unique(where={"email": email}) + return User.from_db(user) if user else None + except Exception as e: + raise DatabaseError(f"Failed to get user by email {email}: {e}") from e + + +async def update_user_email(user_id: str, email: str): + try: + await prisma.user.update(where={"id": user_id}, data={"email": email}) + except Exception as e: + raise DatabaseError( + f"Failed to update user email for user {user_id}: {e}" + ) from e async def create_default_user() -> Optional[User]: user = await prisma.user.find_unique(where={"id": DEFAULT_USER_ID}) if not user: user = await prisma.user.create( - data={ - "id": DEFAULT_USER_ID, - "email": "default@example.com", - "name": "Default User", - } + data=UserCreateInput( + id=DEFAULT_USER_ID, + email="default@example.com", + name="Default User", + ) ) - return User.model_validate(user) - - -async def get_user_metadata(user_id: str) -> UserMetadata: - user = await User.prisma().find_unique_or_raise( - where={"id": user_id}, - ) - - metadata = cast(UserMetadataRaw, user.metadata) - return UserMetadata.model_validate(metadata) - - -async def update_user_metadata(user_id: str, metadata: UserMetadata): - await User.prisma().update( - where={"id": user_id}, - data={"metadata": Json(metadata.model_dump())}, - ) + return User.from_db(user) async def get_user_integrations(user_id: str) -> UserIntegrations: - user = await User.prisma().find_unique_or_raise( + user = await PrismaUser.prisma().find_unique_or_raise( where={"id": user_id}, ) @@ -83,8 +109,8 @@ async def get_user_integrations(user_id: str) -> UserIntegrations: async def update_user_integrations(user_id: str, data: UserIntegrations): - encrypted_data = JSONCryptor().encrypt(data.model_dump()) - await User.prisma().update( + encrypted_data = JSONCryptor().encrypt(data.model_dump(exclude_none=True)) + await PrismaUser.prisma().update( where={"id": user_id}, data={"integrations": encrypted_data}, ) @@ -92,18 +118,23 @@ async def update_user_integrations(user_id: str, data: UserIntegrations): async def migrate_and_encrypt_user_integrations(): """Migrate integration credentials and OAuth states from metadata to integrations column.""" - users = await User.prisma().find_many( + users = await PrismaUser.prisma().find_many( where={ - "metadata": { - "path": ["integration_credentials"], - "not": Json({"a": "yolo"}), # bogus value works to check if key exists - } # type: ignore + "metadata": cast( + JsonFilter, + { + "path": ["integration_credentials"], + "not": SafeJson( + {"a": "yolo"} + ), # bogus value works to check if key exists + }, + ) } ) logger.info(f"Migrating integration credentials for {len(users)} users") for user in users: - raw_metadata = cast(UserMetadataRaw, user.metadata) + raw_metadata = cast(dict, user.metadata) metadata = UserMetadata.model_validate(raw_metadata) # Get existing integrations data @@ -119,12 +150,263 @@ async def migrate_and_encrypt_user_integrations(): await update_user_integrations(user_id=user.id, data=integrations) # Remove from metadata - raw_metadata = dict(raw_metadata) raw_metadata.pop("integration_credentials", None) raw_metadata.pop("integration_oauth_states", None) # Update metadata without integration data - await User.prisma().update( + await PrismaUser.prisma().update( where={"id": user.id}, - data={"metadata": Json(raw_metadata)}, + data={"metadata": SafeJson(raw_metadata)}, + ) + + +async def get_active_user_ids_in_timerange(start_time: str, end_time: str) -> list[str]: + try: + users = await PrismaUser.prisma().find_many( + where={ + "AgentGraphExecutions": { + "some": { + "createdAt": { + "gte": datetime.fromisoformat(start_time), + "lte": datetime.fromisoformat(end_time), + } + } + } + }, + ) + return [user.id for user in users] + + except Exception as e: + raise DatabaseError( + f"Failed to get active user ids in timerange {start_time} to {end_time}: {e}" + ) from e + + +async def get_active_users_ids() -> list[str]: + user_ids = await get_active_user_ids_in_timerange( + (datetime.now() - timedelta(days=30)).isoformat(), + datetime.now().isoformat(), + ) + return user_ids + + +async def get_user_notification_preference(user_id: str) -> NotificationPreference: + try: + user = await PrismaUser.prisma().find_unique_or_raise( + where={"id": user_id}, + ) + + # enable notifications by default if user has no notification preference (shouldn't ever happen though) + preferences: dict[NotificationType, bool] = { + NotificationType.AGENT_RUN: user.notifyOnAgentRun or False, + NotificationType.ZERO_BALANCE: user.notifyOnZeroBalance or False, + NotificationType.LOW_BALANCE: user.notifyOnLowBalance or False, + NotificationType.BLOCK_EXECUTION_FAILED: user.notifyOnBlockExecutionFailed + or False, + NotificationType.CONTINUOUS_AGENT_ERROR: user.notifyOnContinuousAgentError + or False, + NotificationType.DAILY_SUMMARY: user.notifyOnDailySummary or False, + NotificationType.WEEKLY_SUMMARY: user.notifyOnWeeklySummary or False, + NotificationType.MONTHLY_SUMMARY: user.notifyOnMonthlySummary or False, + NotificationType.AGENT_APPROVED: user.notifyOnAgentApproved or False, + NotificationType.AGENT_REJECTED: user.notifyOnAgentRejected or False, + } + daily_limit = user.maxEmailsPerDay or 3 + notification_preference = NotificationPreference( + user_id=user.id, + email=user.email, + preferences=preferences, + daily_limit=daily_limit, + # TODO with other changes later, for now we just will email them + emails_sent_today=0, + last_reset_date=datetime.now(), + ) + return NotificationPreference.model_validate(notification_preference) + + except Exception as e: + raise DatabaseError( + f"Failed to upsert user notification preference for user {user_id}: {e}" + ) from e + + +async def update_user_notification_preference( + user_id: str, data: NotificationPreferenceDTO +) -> NotificationPreference: + try: + update_data: UserUpdateInput = {} + if data.email: + update_data["email"] = data.email + if NotificationType.AGENT_RUN in data.preferences: + update_data["notifyOnAgentRun"] = data.preferences[ + NotificationType.AGENT_RUN + ] + if NotificationType.ZERO_BALANCE in data.preferences: + update_data["notifyOnZeroBalance"] = data.preferences[ + NotificationType.ZERO_BALANCE + ] + if NotificationType.LOW_BALANCE in data.preferences: + update_data["notifyOnLowBalance"] = data.preferences[ + NotificationType.LOW_BALANCE + ] + if NotificationType.BLOCK_EXECUTION_FAILED in data.preferences: + update_data["notifyOnBlockExecutionFailed"] = data.preferences[ + NotificationType.BLOCK_EXECUTION_FAILED + ] + if NotificationType.CONTINUOUS_AGENT_ERROR in data.preferences: + update_data["notifyOnContinuousAgentError"] = data.preferences[ + NotificationType.CONTINUOUS_AGENT_ERROR + ] + if NotificationType.DAILY_SUMMARY in data.preferences: + update_data["notifyOnDailySummary"] = data.preferences[ + NotificationType.DAILY_SUMMARY + ] + if NotificationType.WEEKLY_SUMMARY in data.preferences: + update_data["notifyOnWeeklySummary"] = data.preferences[ + NotificationType.WEEKLY_SUMMARY + ] + if NotificationType.MONTHLY_SUMMARY in data.preferences: + update_data["notifyOnMonthlySummary"] = data.preferences[ + NotificationType.MONTHLY_SUMMARY + ] + if NotificationType.AGENT_APPROVED in data.preferences: + update_data["notifyOnAgentApproved"] = data.preferences[ + NotificationType.AGENT_APPROVED + ] + if NotificationType.AGENT_REJECTED in data.preferences: + update_data["notifyOnAgentRejected"] = data.preferences[ + NotificationType.AGENT_REJECTED + ] + if data.daily_limit: + update_data["maxEmailsPerDay"] = data.daily_limit + + user = await PrismaUser.prisma().update( + where={"id": user_id}, + data=update_data, + ) + if not user: + raise ValueError(f"User not found with ID: {user_id}") + preferences: dict[NotificationType, bool] = { + NotificationType.AGENT_RUN: user.notifyOnAgentRun or True, + NotificationType.ZERO_BALANCE: user.notifyOnZeroBalance or True, + NotificationType.LOW_BALANCE: user.notifyOnLowBalance or True, + NotificationType.BLOCK_EXECUTION_FAILED: user.notifyOnBlockExecutionFailed + or True, + NotificationType.CONTINUOUS_AGENT_ERROR: user.notifyOnContinuousAgentError + or True, + NotificationType.DAILY_SUMMARY: user.notifyOnDailySummary or True, + NotificationType.WEEKLY_SUMMARY: user.notifyOnWeeklySummary or True, + NotificationType.MONTHLY_SUMMARY: user.notifyOnMonthlySummary or True, + NotificationType.AGENT_APPROVED: user.notifyOnAgentApproved or True, + NotificationType.AGENT_REJECTED: user.notifyOnAgentRejected or True, + } + notification_preference = NotificationPreference( + user_id=user.id, + email=user.email, + preferences=preferences, + daily_limit=user.maxEmailsPerDay or 3, + # TODO with other changes later, for now we just will email them + emails_sent_today=0, + last_reset_date=datetime.now(), + ) + return NotificationPreference.model_validate(notification_preference) + + except Exception as e: + raise DatabaseError( + f"Failed to update user notification preference for user {user_id}: {e}" + ) from e + + +async def set_user_email_verification(user_id: str, verified: bool) -> None: + """Set the email verification status for a user.""" + try: + await PrismaUser.prisma().update( + where={"id": user_id}, + data={"emailVerified": verified}, + ) + except Exception as e: + raise DatabaseError( + f"Failed to set email verification status for user {user_id}: {e}" + ) from e + + +async def get_user_email_verification(user_id: str) -> bool: + """Get the email verification status for a user.""" + try: + user = await PrismaUser.prisma().find_unique_or_raise( + where={"id": user_id}, + ) + return user.emailVerified + except Exception as e: + raise DatabaseError( + f"Failed to get email verification status for user {user_id}: {e}" + ) from e + + +def generate_unsubscribe_link(user_id: str) -> str: + """Generate a link to unsubscribe from all notifications""" + # Create an HMAC using a secret key + secret_key = settings.secrets.unsubscribe_secret_key + signature = hmac.new( + secret_key.encode("utf-8"), user_id.encode("utf-8"), hashlib.sha256 + ).digest() + + # Create a token that combines the user_id and signature + token = base64.urlsafe_b64encode( + f"{user_id}:{signature.hex()}".encode("utf-8") + ).decode("utf-8") + logger.info(f"Generating unsubscribe link for user {user_id}") + + base_url = settings.config.platform_base_url + return f"{base_url}/api/email/unsubscribe?token={quote_plus(token)}" + + +async def unsubscribe_user_by_token(token: str) -> None: + """Unsubscribe a user from all notifications using the token""" + try: + # Decode the token + decoded = base64.urlsafe_b64decode(token).decode("utf-8") + user_id, received_signature_hex = decoded.split(":", 1) + + # Verify the signature + secret_key = settings.secrets.unsubscribe_secret_key + expected_signature = hmac.new( + secret_key.encode("utf-8"), user_id.encode("utf-8"), hashlib.sha256 + ).digest() + + if not hmac.compare_digest(expected_signature.hex(), received_signature_hex): + raise ValueError("Invalid token signature") + + user = await get_user_by_id(user_id) + await update_user_notification_preference( + user.id, + NotificationPreferenceDTO( + email=user.email, + daily_limit=0, + preferences={ + NotificationType.AGENT_RUN: False, + NotificationType.ZERO_BALANCE: False, + NotificationType.LOW_BALANCE: False, + NotificationType.BLOCK_EXECUTION_FAILED: False, + NotificationType.CONTINUOUS_AGENT_ERROR: False, + NotificationType.DAILY_SUMMARY: False, + NotificationType.WEEKLY_SUMMARY: False, + NotificationType.MONTHLY_SUMMARY: False, + }, + ), + ) + except Exception as e: + raise DatabaseError(f"Failed to unsubscribe user by token {token}: {e}") from e + + +async def update_user_timezone(user_id: str, timezone: str) -> User: + """Update a user's timezone setting.""" + try: + user = await PrismaUser.prisma().update( + where={"id": user_id}, + data={"timezone": timezone}, ) + if not user: + raise ValueError(f"User not found with ID: {user_id}") + return User.from_db(user) + except Exception as e: + raise DatabaseError(f"Failed to update timezone for user {user_id}: {e}") from e diff --git a/autogpt_platform/backend/backend/db.py b/autogpt_platform/backend/backend/db.py new file mode 100644 index 000000000000..5c59a98a00ef --- /dev/null +++ b/autogpt_platform/backend/backend/db.py @@ -0,0 +1,13 @@ +from backend.app import run_processes +from backend.executor import DatabaseManager + + +def main(): + """ + Run all the processes required for the AutoGPT-server REST API. + """ + run_processes(DatabaseManager()) + + +if __name__ == "__main__": + main() diff --git a/autogpt_platform/backend/backend/exec.py b/autogpt_platform/backend/backend/exec.py index 6e902c64df58..46101a742af9 100644 --- a/autogpt_platform/backend/backend/exec.py +++ b/autogpt_platform/backend/backend/exec.py @@ -1,15 +1,12 @@ from backend.app import run_processes -from backend.executor import DatabaseManager, ExecutionManager +from backend.executor import ExecutionManager def main(): """ Run all the processes required for the AutoGPT-server REST API. """ - run_processes( - DatabaseManager(), - ExecutionManager(), - ) + run_processes(ExecutionManager()) if __name__ == "__main__": diff --git a/autogpt_platform/backend/backend/executor/__init__.py b/autogpt_platform/backend/backend/executor/__init__.py index 59a3595eea29..92d8b5dc58d3 100644 --- a/autogpt_platform/backend/backend/executor/__init__.py +++ b/autogpt_platform/backend/backend/executor/__init__.py @@ -1,9 +1,11 @@ -from .database import DatabaseManager +from .database import DatabaseManager, DatabaseManagerAsyncClient, DatabaseManagerClient from .manager import ExecutionManager -from .scheduler import ExecutionScheduler +from .scheduler import Scheduler __all__ = [ "DatabaseManager", + "DatabaseManagerClient", + "DatabaseManagerAsyncClient", "ExecutionManager", - "ExecutionScheduler", + "Scheduler", ] diff --git a/autogpt_platform/backend/backend/executor/activity_status_generator.py b/autogpt_platform/backend/backend/executor/activity_status_generator.py new file mode 100644 index 000000000000..f45acdd81f0c --- /dev/null +++ b/autogpt_platform/backend/backend/executor/activity_status_generator.py @@ -0,0 +1,434 @@ +""" +Module for generating AI-based activity status for graph executions. +""" + +import json +import logging +from typing import TYPE_CHECKING, Any, NotRequired, TypedDict + +from pydantic import SecretStr + +from backend.blocks.llm import LlmModel, llm_call +from backend.data.block import get_block +from backend.data.execution import ExecutionStatus, NodeExecutionResult +from backend.data.model import APIKeyCredentials, GraphExecutionStats +from backend.util.feature_flag import Flag, is_feature_enabled +from backend.util.retry import func_retry +from backend.util.settings import Settings +from backend.util.truncate import truncate + +if TYPE_CHECKING: + from backend.executor import DatabaseManagerAsyncClient + +logger = logging.getLogger(__name__) + + +class ErrorInfo(TypedDict): + """Type definition for error information.""" + + error: str + execution_id: str + timestamp: str + + +class InputOutputInfo(TypedDict): + """Type definition for input/output information.""" + + execution_id: str + output_data: dict[str, Any] # Used for both input and output data + timestamp: str + + +class NodeInfo(TypedDict): + """Type definition for node information.""" + + node_id: str + block_id: str + block_name: str + block_description: str + execution_count: int + error_count: int + recent_errors: list[ErrorInfo] + recent_outputs: list[InputOutputInfo] + recent_inputs: list[InputOutputInfo] + + +class NodeRelation(TypedDict): + """Type definition for node relation information.""" + + source_node_id: str + sink_node_id: str + source_name: str + sink_name: str + is_static: bool + source_block_name: NotRequired[str] # Optional, only set if block exists + sink_block_name: NotRequired[str] # Optional, only set if block exists + + +def _truncate_uuid(uuid_str: str) -> str: + """Truncate UUID to first segment to reduce payload size.""" + if not uuid_str: + return uuid_str + return uuid_str.split("-")[0] if "-" in uuid_str else uuid_str[:8] + + +async def generate_activity_status_for_execution( + graph_exec_id: str, + graph_id: str, + graph_version: int, + execution_stats: GraphExecutionStats, + db_client: "DatabaseManagerAsyncClient", + user_id: str, + execution_status: ExecutionStatus | None = None, +) -> str | None: + """ + Generate an AI-based activity status summary for a graph execution. + + This function handles all the data collection and AI generation logic, + keeping the manager integration simple. + + Args: + graph_exec_id: The graph execution ID + graph_id: The graph ID + graph_version: The graph version + execution_stats: Execution statistics + db_client: Database client for fetching data + user_id: User ID for LaunchDarkly feature flag evaluation + execution_status: The overall execution status (COMPLETED, FAILED, TERMINATED) + + Returns: + AI-generated activity status string, or None if feature is disabled + """ + # Check LaunchDarkly feature flag for AI activity status generation with full context support + if not await is_feature_enabled(Flag.AI_ACTIVITY_STATUS, user_id): + logger.debug("AI activity status generation is disabled via LaunchDarkly") + return None + + # Check if we have OpenAI API key + try: + settings = Settings() + if not settings.secrets.openai_api_key: + logger.debug( + "OpenAI API key not configured, skipping activity status generation" + ) + return None + + # Get all node executions for this graph execution + node_executions = await db_client.get_node_executions( + graph_exec_id, include_exec_data=True + ) + + # Get graph metadata and full graph structure for name, description, and links + graph_metadata = await db_client.get_graph_metadata(graph_id, graph_version) + graph = await db_client.get_graph(graph_id, graph_version) + + graph_name = graph_metadata.name if graph_metadata else f"Graph {graph_id}" + graph_description = graph_metadata.description if graph_metadata else "" + graph_links = graph.links if graph else [] + + # Build execution data summary + execution_data = _build_execution_summary( + node_executions, + execution_stats, + graph_name, + graph_description, + graph_links, + execution_status, + ) + + # Prepare prompt for AI + prompt = [ + { + "role": "system", + "content": ( + "You are an AI assistant summarizing what you just did for a user in simple, friendly language. " + "Write from the user's perspective about what they accomplished, NOT about technical execution details. " + "Focus on the ACTUAL TASK the user wanted done, not the internal workflow steps. " + "Avoid technical terms like 'workflow', 'execution', 'components', 'nodes', 'processing', etc. " + "Keep it to 3 sentences maximum. Be conversational and human-friendly.\n\n" + "IMPORTANT: Be HONEST about what actually happened:\n" + "- If the input was invalid/nonsensical, say so directly\n" + "- If the task failed, explain what went wrong in simple terms\n" + "- If errors occurred, focus on what the user needs to know\n" + "- Only claim success if the task was genuinely completed\n" + "- Don't sugar-coat failures or present them as helpful feedback\n\n" + "Understanding Errors:\n" + "- Node errors: Individual steps may fail but the overall task might still complete (e.g., one data source fails but others work)\n" + "- Graph error (in overall_status.graph_error): This means the entire execution failed and nothing was accomplished\n" + "- Even if execution shows 'completed', check if critical nodes failed that would prevent the desired outcome\n" + "- Focus on the end result the user wanted, not whether technical steps completed" + ), + }, + { + "role": "user", + "content": ( + f"A user ran '{graph_name}' to accomplish something. Based on this execution data, " + f"write what they achieved in simple, user-friendly terms:\n\n" + f"{json.dumps(execution_data, indent=2)}\n\n" + "CRITICAL: Check overall_status.graph_error FIRST - if present, the entire execution failed.\n" + "Then check individual node errors to understand partial failures.\n\n" + "Write 1-3 sentences about what the user accomplished, such as:\n" + "- 'I analyzed your resume and provided detailed feedback for the IT industry.'\n" + "- 'I couldn't analyze your resume because the input was just nonsensical text.'\n" + "- 'I failed to complete the task due to missing API access.'\n" + "- 'I extracted key information from your documents and organized it into a summary.'\n" + "- 'The task failed to run due to system configuration issues.'\n\n" + "Focus on what ACTUALLY happened, not what was attempted." + ), + }, + ] + + # Log the prompt for debugging purposes + logger.debug( + f"Sending prompt to LLM for graph execution {graph_exec_id}: {json.dumps(prompt, indent=2)}" + ) + + # Create credentials for LLM call + credentials = APIKeyCredentials( + id="openai", + provider="openai", + api_key=SecretStr(settings.secrets.openai_api_key), + title="System OpenAI", + ) + + # Make LLM call using current event loop + activity_status = await _call_llm_direct(credentials, prompt) + + logger.debug( + f"Generated activity status for {graph_exec_id}: {activity_status}" + ) + return activity_status + + except Exception as e: + logger.error( + f"Failed to generate activity status for execution {graph_exec_id}: {str(e)}" + ) + return None + + +def _build_execution_summary( + node_executions: list[NodeExecutionResult], + execution_stats: GraphExecutionStats, + graph_name: str, + graph_description: str, + graph_links: list[Any], + execution_status: ExecutionStatus | None = None, +) -> dict[str, Any]: + """Build a structured summary of execution data for AI analysis.""" + + nodes: list[NodeInfo] = [] + node_execution_counts: dict[str, int] = {} + node_error_counts: dict[str, int] = {} + node_errors: dict[str, list[ErrorInfo]] = {} + node_outputs: dict[str, list[InputOutputInfo]] = {} + node_inputs: dict[str, list[InputOutputInfo]] = {} + input_output_data: dict[str, Any] = {} + node_map: dict[str, NodeInfo] = {} + + # Process node executions + for node_exec in node_executions: + block = get_block(node_exec.block_id) + if not block: + logger.warning( + f"Block {node_exec.block_id} not found for node {node_exec.node_id}" + ) + continue + + # Track execution counts per node + if node_exec.node_id not in node_execution_counts: + node_execution_counts[node_exec.node_id] = 0 + node_execution_counts[node_exec.node_id] += 1 + + # Track errors per node and group them + if node_exec.status == ExecutionStatus.FAILED: + if node_exec.node_id not in node_error_counts: + node_error_counts[node_exec.node_id] = 0 + node_error_counts[node_exec.node_id] += 1 + + # Extract actual error message from output_data + error_message = "Unknown error" + if node_exec.output_data and isinstance(node_exec.output_data, dict): + # Check if error is in output_data + if "error" in node_exec.output_data: + error_output = node_exec.output_data["error"] + if isinstance(error_output, list) and error_output: + error_message = str(error_output[0]) + else: + error_message = str(error_output) + + # Group errors by node_id + if node_exec.node_id not in node_errors: + node_errors[node_exec.node_id] = [] + + node_errors[node_exec.node_id].append( + { + "error": error_message, + "execution_id": _truncate_uuid(node_exec.node_exec_id), + "timestamp": node_exec.add_time.isoformat(), + } + ) + + # Collect output samples for each node (latest executions) + if node_exec.output_data: + if node_exec.node_id not in node_outputs: + node_outputs[node_exec.node_id] = [] + + # Truncate output data to 100 chars to save space + truncated_output = truncate(node_exec.output_data, 100) + + node_outputs[node_exec.node_id].append( + { + "execution_id": _truncate_uuid(node_exec.node_exec_id), + "output_data": truncated_output, + "timestamp": node_exec.add_time.isoformat(), + } + ) + + # Collect input samples for each node (latest executions) + if node_exec.input_data: + if node_exec.node_id not in node_inputs: + node_inputs[node_exec.node_id] = [] + + # Truncate input data to 100 chars to save space + truncated_input = truncate(node_exec.input_data, 100) + + node_inputs[node_exec.node_id].append( + { + "execution_id": _truncate_uuid(node_exec.node_exec_id), + "output_data": truncated_input, # Reuse field name for consistency + "timestamp": node_exec.add_time.isoformat(), + } + ) + + # Build node data (only add unique nodes) + if node_exec.node_id not in node_map: + node_data: NodeInfo = { + "node_id": _truncate_uuid(node_exec.node_id), + "block_id": _truncate_uuid(node_exec.block_id), + "block_name": block.name, + "block_description": block.description or "", + "execution_count": 0, # Will be set later + "error_count": 0, # Will be set later + "recent_errors": [], # Will be set later + "recent_outputs": [], # Will be set later + "recent_inputs": [], # Will be set later + } + nodes.append(node_data) + node_map[node_exec.node_id] = node_data + + # Store input/output data for special blocks (input/output blocks) + if block.name in ["AgentInputBlock", "AgentOutputBlock", "UserInputBlock"]: + if node_exec.input_data: + input_output_data[f"{node_exec.node_id}_inputs"] = dict( + node_exec.input_data + ) + if node_exec.output_data: + input_output_data[f"{node_exec.node_id}_outputs"] = dict( + node_exec.output_data + ) + + # Add execution and error counts to node data, plus limited errors and output samples + for node in nodes: + # Use original node_id for lookups (before truncation) + original_node_id = None + for orig_id, node_data in node_map.items(): + if node_data == node: + original_node_id = orig_id + break + + if original_node_id: + node["execution_count"] = node_execution_counts.get(original_node_id, 0) + node["error_count"] = node_error_counts.get(original_node_id, 0) + + # Add limited errors for this node (latest 10 or first 5 + last 5) + if original_node_id in node_errors: + node_error_list = node_errors[original_node_id] + if len(node_error_list) <= 10: + node["recent_errors"] = node_error_list + else: + # First 5 + last 5 if more than 10 errors + node["recent_errors"] = node_error_list[:5] + node_error_list[-5:] + + # Add latest output samples (latest 3) + if original_node_id in node_outputs: + node_output_list = node_outputs[original_node_id] + # Sort by timestamp if available, otherwise take last 3 + if node_output_list and node_output_list[0].get("timestamp"): + node_output_list.sort( + key=lambda x: x.get("timestamp", ""), reverse=True + ) + node["recent_outputs"] = node_output_list[:3] + + # Add latest input samples (latest 3) + if original_node_id in node_inputs: + node_input_list = node_inputs[original_node_id] + # Sort by timestamp if available, otherwise take last 3 + if node_input_list and node_input_list[0].get("timestamp"): + node_input_list.sort( + key=lambda x: x.get("timestamp", ""), reverse=True + ) + node["recent_inputs"] = node_input_list[:3] + + # Build node relations from graph links + node_relations: list[NodeRelation] = [] + for link in graph_links: + # Include link details with source and sink information (truncated UUIDs) + relation: NodeRelation = { + "source_node_id": _truncate_uuid(link.source_id), + "sink_node_id": _truncate_uuid(link.sink_id), + "source_name": link.source_name, + "sink_name": link.sink_name, + "is_static": link.is_static if hasattr(link, "is_static") else False, + } + + # Add block names if nodes exist in our map + if link.source_id in node_map: + relation["source_block_name"] = node_map[link.source_id]["block_name"] + if link.sink_id in node_map: + relation["sink_block_name"] = node_map[link.sink_id]["block_name"] + + node_relations.append(relation) + + # Build overall summary + return { + "graph_info": {"name": graph_name, "description": graph_description}, + "nodes": nodes, + "node_relations": node_relations, + "input_output_data": input_output_data, + "overall_status": { + "total_nodes_in_graph": len(nodes), + "total_executions": execution_stats.node_count, + "total_errors": execution_stats.node_error_count, + "execution_time_seconds": execution_stats.walltime, + "has_errors": bool( + execution_stats.error or execution_stats.node_error_count > 0 + ), + "graph_error": ( + str(execution_stats.error) if execution_stats.error else None + ), + "graph_execution_status": ( + execution_status.value if execution_status else None + ), + }, + } + + +@func_retry +async def _call_llm_direct( + credentials: APIKeyCredentials, prompt: list[dict[str, str]] +) -> str: + """Make direct LLM call.""" + + response = await llm_call( + credentials=credentials, + llm_model=LlmModel.GPT4O_MINI, + prompt=prompt, + json_format=False, + max_tokens=150, + compress_prompt_to_fit=True, + ) + + if response and response.response: + return response.response.strip() + else: + return "Unable to generate activity summary" diff --git a/autogpt_platform/backend/backend/executor/activity_status_generator_test.py b/autogpt_platform/backend/backend/executor/activity_status_generator_test.py new file mode 100644 index 000000000000..08ff3a7c2ae5 --- /dev/null +++ b/autogpt_platform/backend/backend/executor/activity_status_generator_test.py @@ -0,0 +1,702 @@ +""" +Tests for activity status generator functionality. +""" + +from datetime import datetime, timezone +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.blocks.llm import LLMResponse +from backend.data.execution import ExecutionStatus, NodeExecutionResult +from backend.data.model import GraphExecutionStats +from backend.executor.activity_status_generator import ( + _build_execution_summary, + _call_llm_direct, + generate_activity_status_for_execution, +) + + +@pytest.fixture +def mock_node_executions(): + """Create mock node executions for testing.""" + return [ + NodeExecutionResult( + user_id="test_user", + graph_id="test_graph", + graph_version=1, + graph_exec_id="test_exec", + node_exec_id="123e4567-e89b-12d3-a456-426614174001", + node_id="456e7890-e89b-12d3-a456-426614174002", + block_id="789e1234-e89b-12d3-a456-426614174003", + status=ExecutionStatus.COMPLETED, + input_data={"user_input": "Hello, world!"}, + output_data={"processed_input": ["Hello, world!"]}, + add_time=datetime.now(timezone.utc), + queue_time=None, + start_time=None, + end_time=None, + ), + NodeExecutionResult( + user_id="test_user", + graph_id="test_graph", + graph_version=1, + graph_exec_id="test_exec", + node_exec_id="234e5678-e89b-12d3-a456-426614174004", + node_id="567e8901-e89b-12d3-a456-426614174005", + block_id="890e2345-e89b-12d3-a456-426614174006", + status=ExecutionStatus.COMPLETED, + input_data={"data": "Hello, world!"}, + output_data={"result": ["Processed data"]}, + add_time=datetime.now(timezone.utc), + queue_time=None, + start_time=None, + end_time=None, + ), + NodeExecutionResult( + user_id="test_user", + graph_id="test_graph", + graph_version=1, + graph_exec_id="test_exec", + node_exec_id="345e6789-e89b-12d3-a456-426614174007", + node_id="678e9012-e89b-12d3-a456-426614174008", + block_id="901e3456-e89b-12d3-a456-426614174009", + status=ExecutionStatus.FAILED, + input_data={"final_data": "Processed data"}, + output_data={ + "error": ["Connection timeout: Unable to reach external service"] + }, + add_time=datetime.now(timezone.utc), + queue_time=None, + start_time=None, + end_time=None, + ), + ] + + +@pytest.fixture +def mock_execution_stats(): + """Create mock execution stats for testing.""" + return GraphExecutionStats( + walltime=2.5, + cputime=1.8, + nodes_walltime=2.0, + nodes_cputime=1.5, + node_count=3, + node_error_count=1, + cost=10, + error=None, + ) + + +@pytest.fixture +def mock_execution_stats_with_graph_error(): + """Create mock execution stats with graph-level error.""" + return GraphExecutionStats( + walltime=2.5, + cputime=1.8, + nodes_walltime=2.0, + nodes_cputime=1.5, + node_count=3, + node_error_count=1, + cost=10, + error="Graph execution failed: Invalid API credentials", + ) + + +@pytest.fixture +def mock_blocks(): + """Create mock blocks for testing.""" + input_block = MagicMock() + input_block.name = "AgentInputBlock" + input_block.description = "Handles user input" + + process_block = MagicMock() + process_block.name = "ProcessingBlock" + process_block.description = "Processes data" + + output_block = MagicMock() + output_block.name = "AgentOutputBlock" + output_block.description = "Provides output to user" + + return { + "789e1234-e89b-12d3-a456-426614174003": input_block, + "890e2345-e89b-12d3-a456-426614174006": process_block, + "901e3456-e89b-12d3-a456-426614174009": output_block, + "process_block_id": process_block, # Keep old key for different error format test + } + + +class TestBuildExecutionSummary: + """Tests for _build_execution_summary function.""" + + def test_build_summary_with_successful_execution( + self, mock_node_executions, mock_execution_stats, mock_blocks + ): + """Test building summary for successful execution.""" + # Create mock links with realistic UUIDs + mock_links = [ + MagicMock( + source_id="456e7890-e89b-12d3-a456-426614174002", + sink_id="567e8901-e89b-12d3-a456-426614174005", + source_name="output", + sink_name="input", + is_static=False, + ) + ] + + with patch( + "backend.executor.activity_status_generator.get_block" + ) as mock_get_block: + mock_get_block.side_effect = lambda block_id: mock_blocks.get(block_id) + + summary = _build_execution_summary( + mock_node_executions[:2], + mock_execution_stats, + "Test Graph", + "A test graph for processing", + mock_links, + ExecutionStatus.COMPLETED, + ) + + # Check graph info + assert summary["graph_info"]["name"] == "Test Graph" + assert summary["graph_info"]["description"] == "A test graph for processing" + + # Check nodes with per-node counts + assert len(summary["nodes"]) == 2 + assert summary["nodes"][0]["block_name"] == "AgentInputBlock" + assert summary["nodes"][0]["execution_count"] == 1 + assert summary["nodes"][0]["error_count"] == 0 + assert summary["nodes"][1]["block_name"] == "ProcessingBlock" + assert summary["nodes"][1]["execution_count"] == 1 + assert summary["nodes"][1]["error_count"] == 0 + + # Check node relations (UUIDs are truncated to first segment) + assert len(summary["node_relations"]) == 1 + assert ( + summary["node_relations"][0]["source_node_id"] == "456e7890" + ) # Truncated + assert ( + summary["node_relations"][0]["sink_node_id"] == "567e8901" + ) # Truncated + assert ( + summary["node_relations"][0]["source_block_name"] == "AgentInputBlock" + ) + assert summary["node_relations"][0]["sink_block_name"] == "ProcessingBlock" + + # Check overall status + assert summary["overall_status"]["total_nodes_in_graph"] == 2 + assert summary["overall_status"]["total_executions"] == 3 + assert summary["overall_status"]["total_errors"] == 1 + assert summary["overall_status"]["execution_time_seconds"] == 2.5 + assert summary["overall_status"]["graph_execution_status"] == "COMPLETED" + + # Check input/output data (using actual node UUIDs) + assert ( + "456e7890-e89b-12d3-a456-426614174002_inputs" + in summary["input_output_data"] + ) + assert ( + "456e7890-e89b-12d3-a456-426614174002_outputs" + in summary["input_output_data"] + ) + + def test_build_summary_with_failed_execution( + self, mock_node_executions, mock_execution_stats, mock_blocks + ): + """Test building summary for execution with failures.""" + mock_links = [] # No links for this test + + with patch( + "backend.executor.activity_status_generator.get_block" + ) as mock_get_block: + mock_get_block.side_effect = lambda block_id: mock_blocks.get(block_id) + + summary = _build_execution_summary( + mock_node_executions, + mock_execution_stats, + "Failed Graph", + "Test with failures", + mock_links, + ExecutionStatus.FAILED, + ) + + # Check that errors are now in node's recent_errors field + # Find the output node (with truncated UUID) + output_node = next( + n for n in summary["nodes"] if n["node_id"] == "678e9012" # Truncated + ) + assert output_node["error_count"] == 1 + assert output_node["execution_count"] == 1 + + # Check recent_errors field + assert "recent_errors" in output_node + assert len(output_node["recent_errors"]) == 1 + assert ( + output_node["recent_errors"][0]["error"] + == "Connection timeout: Unable to reach external service" + ) + assert ( + "execution_id" in output_node["recent_errors"][0] + ) # Should include execution ID + + def test_build_summary_with_missing_blocks( + self, mock_node_executions, mock_execution_stats + ): + """Test building summary when blocks are missing.""" + with patch( + "backend.executor.activity_status_generator.get_block" + ) as mock_get_block: + mock_get_block.return_value = None + + summary = _build_execution_summary( + mock_node_executions, + mock_execution_stats, + "Missing Blocks Graph", + "Test with missing blocks", + [], + ExecutionStatus.COMPLETED, + ) + + # Should handle missing blocks gracefully + assert len(summary["nodes"]) == 0 + # No top-level errors field anymore, errors are in nodes' recent_errors + assert summary["graph_info"]["name"] == "Missing Blocks Graph" + + def test_build_summary_with_graph_error( + self, mock_node_executions, mock_execution_stats_with_graph_error, mock_blocks + ): + """Test building summary with graph-level error.""" + mock_links = [] + + with patch( + "backend.executor.activity_status_generator.get_block" + ) as mock_get_block: + mock_get_block.side_effect = lambda block_id: mock_blocks.get(block_id) + + summary = _build_execution_summary( + mock_node_executions, + mock_execution_stats_with_graph_error, + "Graph with Error", + "Test with graph error", + mock_links, + ExecutionStatus.FAILED, + ) + + # Check that graph error is included in overall status + assert summary["overall_status"]["has_errors"] is True + assert ( + summary["overall_status"]["graph_error"] + == "Graph execution failed: Invalid API credentials" + ) + assert summary["overall_status"]["total_errors"] == 1 + assert summary["overall_status"]["graph_execution_status"] == "FAILED" + + def test_build_summary_with_different_error_formats( + self, mock_execution_stats, mock_blocks + ): + """Test building summary with different error formats.""" + # Create node executions with different error formats and realistic UUIDs + mock_executions = [ + NodeExecutionResult( + user_id="test_user", + graph_id="test_graph", + graph_version=1, + graph_exec_id="test_exec", + node_exec_id="111e2222-e89b-12d3-a456-426614174010", + node_id="333e4444-e89b-12d3-a456-426614174011", + block_id="process_block_id", + status=ExecutionStatus.FAILED, + input_data={}, + output_data={"error": ["Simple string error message"]}, + add_time=datetime.now(timezone.utc), + queue_time=None, + start_time=None, + end_time=None, + ), + NodeExecutionResult( + user_id="test_user", + graph_id="test_graph", + graph_version=1, + graph_exec_id="test_exec", + node_exec_id="555e6666-e89b-12d3-a456-426614174012", + node_id="777e8888-e89b-12d3-a456-426614174013", + block_id="process_block_id", + status=ExecutionStatus.FAILED, + input_data={}, + output_data={}, # No error in output + add_time=datetime.now(timezone.utc), + queue_time=None, + start_time=None, + end_time=None, + ), + ] + + with patch( + "backend.executor.activity_status_generator.get_block" + ) as mock_get_block: + mock_get_block.side_effect = lambda block_id: mock_blocks.get(block_id) + + summary = _build_execution_summary( + mock_executions, + mock_execution_stats, + "Error Test Graph", + "Testing error formats", + [], + ExecutionStatus.FAILED, + ) + + # Check different error formats - errors are now in nodes' recent_errors + error_nodes = [n for n in summary["nodes"] if n.get("recent_errors")] + assert len(error_nodes) == 2 + + # String error format - find node with truncated ID + string_error_node = next( + n for n in summary["nodes"] if n["node_id"] == "333e4444" # Truncated + ) + assert len(string_error_node["recent_errors"]) == 1 + assert ( + string_error_node["recent_errors"][0]["error"] + == "Simple string error message" + ) + + # No error output format - find node with truncated ID + no_error_node = next( + n for n in summary["nodes"] if n["node_id"] == "777e8888" # Truncated + ) + assert len(no_error_node["recent_errors"]) == 1 + assert no_error_node["recent_errors"][0]["error"] == "Unknown error" + + +class TestLLMCall: + """Tests for LLM calling functionality.""" + + @pytest.mark.asyncio + async def test_call_llm_direct_success(self): + """Test successful LLM call.""" + from pydantic import SecretStr + + from backend.data.model import APIKeyCredentials + + mock_response = LLMResponse( + raw_response={}, + prompt=[], + response="Agent successfully processed user input and generated response.", + tool_calls=None, + prompt_tokens=50, + completion_tokens=20, + ) + + with patch( + "backend.executor.activity_status_generator.llm_call" + ) as mock_llm_call: + mock_llm_call.return_value = mock_response + + credentials = APIKeyCredentials( + id="test", + provider="openai", + api_key=SecretStr("test_key"), + title="Test", + ) + + prompt = [{"role": "user", "content": "Test prompt"}] + + result = await _call_llm_direct(credentials, prompt) + + assert ( + result + == "Agent successfully processed user input and generated response." + ) + mock_llm_call.assert_called_once() + + @pytest.mark.asyncio + async def test_call_llm_direct_no_response(self): + """Test LLM call with no response.""" + from pydantic import SecretStr + + from backend.data.model import APIKeyCredentials + + with patch( + "backend.executor.activity_status_generator.llm_call" + ) as mock_llm_call: + mock_llm_call.return_value = None + + credentials = APIKeyCredentials( + id="test", + provider="openai", + api_key=SecretStr("test_key"), + title="Test", + ) + + prompt = [{"role": "user", "content": "Test prompt"}] + + result = await _call_llm_direct(credentials, prompt) + + assert result == "Unable to generate activity summary" + + +class TestGenerateActivityStatusForExecution: + """Tests for the main generate_activity_status_for_execution function.""" + + @pytest.mark.asyncio + async def test_generate_status_success( + self, mock_node_executions, mock_execution_stats, mock_blocks + ): + """Test successful activity status generation.""" + mock_db_client = AsyncMock() + mock_db_client.get_node_executions.return_value = mock_node_executions + + mock_graph_metadata = MagicMock() + mock_graph_metadata.name = "Test Agent" + mock_graph_metadata.description = "A test agent" + mock_db_client.get_graph_metadata.return_value = mock_graph_metadata + + mock_graph = MagicMock() + mock_graph.links = [] + mock_db_client.get_graph.return_value = mock_graph + + with patch( + "backend.executor.activity_status_generator.get_block" + ) as mock_get_block, patch( + "backend.executor.activity_status_generator.Settings" + ) as mock_settings, patch( + "backend.executor.activity_status_generator._call_llm_direct" + ) as mock_llm, patch( + "backend.executor.activity_status_generator.is_feature_enabled", + return_value=True, + ): + + mock_get_block.side_effect = lambda block_id: mock_blocks.get(block_id) + mock_settings.return_value.secrets.openai_api_key = "test_key" + mock_llm.return_value = ( + "I analyzed your data and provided the requested insights." + ) + + result = await generate_activity_status_for_execution( + graph_exec_id="test_exec", + graph_id="test_graph", + graph_version=1, + execution_stats=mock_execution_stats, + db_client=mock_db_client, + user_id="test_user", + ) + + assert result == "I analyzed your data and provided the requested insights." + mock_db_client.get_node_executions.assert_called_once() + mock_db_client.get_graph_metadata.assert_called_once() + mock_db_client.get_graph.assert_called_once() + mock_llm.assert_called_once() + + @pytest.mark.asyncio + async def test_generate_status_feature_disabled(self, mock_execution_stats): + """Test activity status generation when feature is disabled.""" + mock_db_client = AsyncMock() + + with patch( + "backend.executor.activity_status_generator.is_feature_enabled", + return_value=False, + ): + result = await generate_activity_status_for_execution( + graph_exec_id="test_exec", + graph_id="test_graph", + graph_version=1, + execution_stats=mock_execution_stats, + db_client=mock_db_client, + user_id="test_user", + ) + + assert result is None + mock_db_client.get_node_executions.assert_not_called() + + @pytest.mark.asyncio + async def test_generate_status_no_api_key(self, mock_execution_stats): + """Test activity status generation with no API key.""" + mock_db_client = AsyncMock() + + with patch( + "backend.executor.activity_status_generator.Settings" + ) as mock_settings, patch( + "backend.executor.activity_status_generator.is_feature_enabled", + return_value=True, + ): + mock_settings.return_value.secrets.openai_api_key = "" + + result = await generate_activity_status_for_execution( + graph_exec_id="test_exec", + graph_id="test_graph", + graph_version=1, + execution_stats=mock_execution_stats, + db_client=mock_db_client, + user_id="test_user", + ) + + assert result is None + mock_db_client.get_node_executions.assert_not_called() + + @pytest.mark.asyncio + async def test_generate_status_exception_handling(self, mock_execution_stats): + """Test activity status generation with exception.""" + mock_db_client = AsyncMock() + mock_db_client.get_node_executions.side_effect = Exception("Database error") + + with patch( + "backend.executor.activity_status_generator.Settings" + ) as mock_settings, patch( + "backend.executor.activity_status_generator.is_feature_enabled", + return_value=True, + ): + mock_settings.return_value.secrets.openai_api_key = "test_key" + + result = await generate_activity_status_for_execution( + graph_exec_id="test_exec", + graph_id="test_graph", + graph_version=1, + execution_stats=mock_execution_stats, + db_client=mock_db_client, + user_id="test_user", + ) + + assert result is None + + @pytest.mark.asyncio + async def test_generate_status_with_graph_name_fallback( + self, mock_node_executions, mock_execution_stats, mock_blocks + ): + """Test activity status generation with graph name fallback.""" + mock_db_client = AsyncMock() + mock_db_client.get_node_executions.return_value = mock_node_executions + mock_db_client.get_graph_metadata.return_value = None # No metadata + mock_db_client.get_graph.return_value = None # No graph + + with patch( + "backend.executor.activity_status_generator.get_block" + ) as mock_get_block, patch( + "backend.executor.activity_status_generator.Settings" + ) as mock_settings, patch( + "backend.executor.activity_status_generator._call_llm_direct" + ) as mock_llm, patch( + "backend.executor.activity_status_generator.is_feature_enabled", + return_value=True, + ): + + mock_get_block.side_effect = lambda block_id: mock_blocks.get(block_id) + mock_settings.return_value.secrets.openai_api_key = "test_key" + mock_llm.return_value = "Agent completed execution." + + result = await generate_activity_status_for_execution( + graph_exec_id="test_exec", + graph_id="test_graph", + graph_version=1, + execution_stats=mock_execution_stats, + db_client=mock_db_client, + user_id="test_user", + ) + + assert result == "Agent completed execution." + # Should use fallback graph name in prompt + call_args = mock_llm.call_args[0][1] # prompt argument + assert "Graph test_graph" in call_args[1]["content"] + + +class TestIntegration: + """Integration tests to verify the complete flow.""" + + @pytest.mark.asyncio + async def test_full_integration_flow( + self, mock_node_executions, mock_execution_stats, mock_blocks + ): + """Test the complete integration flow.""" + mock_db_client = AsyncMock() + mock_db_client.get_node_executions.return_value = mock_node_executions + + mock_graph_metadata = MagicMock() + mock_graph_metadata.name = "Test Integration Agent" + mock_graph_metadata.description = "Integration test agent" + mock_db_client.get_graph_metadata.return_value = mock_graph_metadata + + mock_graph = MagicMock() + mock_graph.links = [] + mock_db_client.get_graph.return_value = mock_graph + + expected_activity = "I processed user input but failed during final output generation due to system error." + + with patch( + "backend.executor.activity_status_generator.get_block" + ) as mock_get_block, patch( + "backend.executor.activity_status_generator.Settings" + ) as mock_settings, patch( + "backend.executor.activity_status_generator.llm_call" + ) as mock_llm_call, patch( + "backend.executor.activity_status_generator.is_feature_enabled", + return_value=True, + ): + + mock_get_block.side_effect = lambda block_id: mock_blocks.get(block_id) + mock_settings.return_value.secrets.openai_api_key = "test_key" + + mock_response = LLMResponse( + raw_response={}, + prompt=[], + response=expected_activity, + tool_calls=None, + prompt_tokens=100, + completion_tokens=30, + ) + mock_llm_call.return_value = mock_response + + result = await generate_activity_status_for_execution( + graph_exec_id="test_exec", + graph_id="test_graph", + graph_version=1, + execution_stats=mock_execution_stats, + db_client=mock_db_client, + user_id="test_user", + ) + + assert result == expected_activity + + # Verify the correct data was passed to LLM + llm_call_args = mock_llm_call.call_args + prompt = llm_call_args[1]["prompt"] + + # Check system prompt + assert prompt[0]["role"] == "system" + assert "user's perspective" in prompt[0]["content"] + + # Check user prompt contains expected data + user_content = prompt[1]["content"] + assert "Test Integration Agent" in user_content + assert "user-friendly terms" in user_content.lower() + + # Verify that execution data is present in the prompt + assert "{" in user_content # Should contain JSON data + assert "overall_status" in user_content + + @pytest.mark.asyncio + async def test_manager_integration_with_disabled_feature( + self, mock_execution_stats + ): + """Test that when feature returns None, manager doesn't set activity_status.""" + mock_db_client = AsyncMock() + + with patch( + "backend.executor.activity_status_generator.is_feature_enabled", + return_value=False, + ): + result = await generate_activity_status_for_execution( + graph_exec_id="test_exec", + graph_id="test_graph", + graph_version=1, + execution_stats=mock_execution_stats, + db_client=mock_db_client, + user_id="test_user", + ) + + # Should return None when disabled + assert result is None + + # Verify no database calls were made + mock_db_client.get_node_executions.assert_not_called() + mock_db_client.get_graph_metadata.assert_not_called() + mock_db_client.get_graph.assert_not_called() diff --git a/autogpt_platform/backend/backend/executor/database.py b/autogpt_platform/backend/backend/executor/database.py index 4016363c1ab8..2607b24843df 100644 --- a/autogpt_platform/backend/backend/executor/database.py +++ b/autogpt_platform/backend/backend/executor/database.py @@ -1,94 +1,253 @@ -from functools import wraps -from typing import Any, Callable, Concatenate, Coroutine, ParamSpec, TypeVar, cast +import logging +from typing import Callable, Concatenate, ParamSpec, TypeVar, cast -from backend.data.credit import get_user_credit_model +from backend.data import db +from backend.data.credit import UsageTransactionMetadata, get_user_credit_model from backend.data.execution import ( - ExecutionResult, - RedisExecutionEventBus, create_graph_execution, - get_execution_results, - get_incomplete_executions, - get_latest_execution, - update_execution_status, + get_block_error_stats, + get_execution_kv_data, + get_graph_execution_meta, + get_graph_executions, + get_latest_node_execution, + get_node_execution, + get_node_executions, + set_execution_kv_data, + update_graph_execution_start_time, update_graph_execution_stats, - update_node_execution_stats, + update_node_execution_status, + update_node_execution_status_batch, upsert_execution_input, upsert_execution_output, ) -from backend.data.graph import get_graph, get_node +from backend.data.generate_data import get_user_execution_summary_data +from backend.data.graph import ( + get_connected_output_nodes, + get_graph, + get_graph_metadata, + get_node, +) +from backend.data.notifications import ( + create_or_add_to_user_notification_batch, + empty_user_notification_batch, + get_all_batches_by_type, + get_user_notification_batch, + get_user_notification_oldest_message_in_batch, +) from backend.data.user import ( + get_active_user_ids_in_timerange, + get_user_email_by_id, + get_user_email_verification, get_user_integrations, - get_user_metadata, + get_user_notification_preference, update_user_integrations, - update_user_metadata, ) -from backend.util.service import AppService, expose, register_pydantic_serializers +from backend.server.v2.library.db import add_store_agent_to_library, list_library_agents +from backend.server.v2.store.db import get_store_agent_details, get_store_agents +from backend.util.service import ( + AppService, + AppServiceClient, + UnhealthyServiceError, + endpoint_to_sync, + expose, +) from backend.util.settings import Config +config = Config() +_user_credit_model = get_user_credit_model() +logger = logging.getLogger(__name__) P = ParamSpec("P") R = TypeVar("R") -config = Config() + + +async def _spend_credits( + user_id: str, cost: int, metadata: UsageTransactionMetadata +) -> int: + return await _user_credit_model.spend_credits(user_id, cost, metadata) + + +async def _get_credits(user_id: str) -> int: + return await _user_credit_model.get_credits(user_id) class DatabaseManager(AppService): - def __init__(self): - super().__init__() - self.use_db = True - self.use_redis = True - self.event_queue = RedisExecutionEventBus() + + def run_service(self) -> None: + logger.info(f"[{self.service_name}] ⏳ Connecting to Database...") + self.run_and_wait(db.connect()) + super().run_service() + + def cleanup(self): + super().cleanup() + logger.info(f"[{self.service_name}] ⏳ Disconnecting Database...") + self.run_and_wait(db.disconnect()) + + async def health_check(self) -> str: + if not db.is_connected(): + raise UnhealthyServiceError("Database is not connected") + return await super().health_check() @classmethod def get_port(cls) -> int: return config.database_api_port - @expose - def send_execution_update(self, execution_result: ExecutionResult): - self.event_queue.publish(execution_result) - @staticmethod - def exposed_run_and_wait( - f: Callable[P, Coroutine[None, None, R]] + def _( + f: Callable[P, R], name: str | None = None ) -> Callable[Concatenate[object, P], R]: - @expose - @wraps(f) - def wrapper(self, *args: P.args, **kwargs: P.kwargs) -> R: - coroutine = f(*args, **kwargs) - res = self.run_and_wait(coroutine) - return res + if name is not None: + f.__name__ = name + return cast(Callable[Concatenate[object, P], R], expose(f)) + + # Executions + get_graph_executions = _(get_graph_executions) + get_graph_execution_meta = _(get_graph_execution_meta) + create_graph_execution = _(create_graph_execution) + get_node_execution = _(get_node_execution) + get_node_executions = _(get_node_executions) + get_latest_node_execution = _(get_latest_node_execution) + update_node_execution_status = _(update_node_execution_status) + update_node_execution_status_batch = _(update_node_execution_status_batch) + update_graph_execution_start_time = _(update_graph_execution_start_time) + update_graph_execution_stats = _(update_graph_execution_stats) + upsert_execution_input = _(upsert_execution_input) + upsert_execution_output = _(upsert_execution_output) + get_execution_kv_data = _(get_execution_kv_data) + set_execution_kv_data = _(set_execution_kv_data) + get_block_error_stats = _(get_block_error_stats) + + # Graphs + get_node = _(get_node) + get_graph = _(get_graph) + get_connected_output_nodes = _(get_connected_output_nodes) + get_graph_metadata = _(get_graph_metadata) + + # Credits + spend_credits = _(_spend_credits, name="spend_credits") + get_credits = _(_get_credits, name="get_credits") + + # User + User Metadata + User Integrations + get_user_integrations = _(get_user_integrations) + update_user_integrations = _(update_user_integrations) + + # User Comms - async + get_active_user_ids_in_timerange = _(get_active_user_ids_in_timerange) + get_user_email_by_id = _(get_user_email_by_id) + get_user_email_verification = _(get_user_email_verification) + get_user_notification_preference = _(get_user_notification_preference) + + # Notifications - async + create_or_add_to_user_notification_batch = _( + create_or_add_to_user_notification_batch + ) + empty_user_notification_batch = _(empty_user_notification_batch) + get_all_batches_by_type = _(get_all_batches_by_type) + get_user_notification_batch = _(get_user_notification_batch) + get_user_notification_oldest_message_in_batch = _( + get_user_notification_oldest_message_in_batch + ) + + # Library + list_library_agents = _(list_library_agents) + add_store_agent_to_library = _(add_store_agent_to_library) + + # Store + get_store_agents = _(get_store_agents) + get_store_agent_details = _(get_store_agent_details) + + # Summary data - async + get_user_execution_summary_data = _(get_user_execution_summary_data) - # Register serializers for annotations on bare function - register_pydantic_serializers(f) - return wrapper +class DatabaseManagerClient(AppServiceClient): + d = DatabaseManager + _ = endpoint_to_sync + + @classmethod + def get_service_type(cls): + return DatabaseManager # Executions - create_graph_execution = exposed_run_and_wait(create_graph_execution) - get_execution_results = exposed_run_and_wait(get_execution_results) - get_incomplete_executions = exposed_run_and_wait(get_incomplete_executions) - get_latest_execution = exposed_run_and_wait(get_latest_execution) - update_execution_status = exposed_run_and_wait(update_execution_status) - update_graph_execution_stats = exposed_run_and_wait(update_graph_execution_stats) - update_node_execution_stats = exposed_run_and_wait(update_node_execution_stats) - upsert_execution_input = exposed_run_and_wait(upsert_execution_input) - upsert_execution_output = exposed_run_and_wait(upsert_execution_output) + get_graph_executions = _(d.get_graph_executions) + get_graph_execution_meta = _(d.get_graph_execution_meta) + get_node_executions = _(d.get_node_executions) + update_node_execution_status = _(d.update_node_execution_status) + update_graph_execution_start_time = _(d.update_graph_execution_start_time) + update_graph_execution_stats = _(d.update_graph_execution_stats) + upsert_execution_output = _(d.upsert_execution_output) # Graphs - get_node = exposed_run_and_wait(get_node) - get_graph = exposed_run_and_wait(get_graph) + get_graph_metadata = _(d.get_graph_metadata) # Credits - user_credit_model = get_user_credit_model() - get_or_refill_credit = cast( - Callable[[Any, str], int], - exposed_run_and_wait(user_credit_model.get_or_refill_credit), + spend_credits = _(d.spend_credits) + get_credits = _(d.get_credits) + + # Block error monitoring + get_block_error_stats = _(d.get_block_error_stats) + + # User Emails + get_user_email_by_id = _(d.get_user_email_by_id) + + # Library + list_library_agents = _(d.list_library_agents) + add_store_agent_to_library = _(d.add_store_agent_to_library) + + # Store + get_store_agents = _(d.get_store_agents) + get_store_agent_details = _(d.get_store_agent_details) + + +class DatabaseManagerAsyncClient(AppServiceClient): + d = DatabaseManager + + @classmethod + def get_service_type(cls): + return DatabaseManager + + create_graph_execution = d.create_graph_execution + get_connected_output_nodes = d.get_connected_output_nodes + get_latest_node_execution = d.get_latest_node_execution + get_graph = d.get_graph + get_graph_metadata = d.get_graph_metadata + get_graph_execution_meta = d.get_graph_execution_meta + get_node = d.get_node + get_node_execution = d.get_node_execution + get_node_executions = d.get_node_executions + get_user_integrations = d.get_user_integrations + upsert_execution_input = d.upsert_execution_input + upsert_execution_output = d.upsert_execution_output + update_graph_execution_stats = d.update_graph_execution_stats + update_node_execution_status = d.update_node_execution_status + update_node_execution_status_batch = d.update_node_execution_status_batch + update_user_integrations = d.update_user_integrations + get_execution_kv_data = d.get_execution_kv_data + set_execution_kv_data = d.set_execution_kv_data + + # User Comms + get_active_user_ids_in_timerange = d.get_active_user_ids_in_timerange + get_user_email_by_id = d.get_user_email_by_id + get_user_email_verification = d.get_user_email_verification + get_user_notification_preference = d.get_user_notification_preference + + # Notifications + create_or_add_to_user_notification_batch = ( + d.create_or_add_to_user_notification_batch ) - spend_credits = cast( - Callable[[Any, str, int, str, dict[str, str], float, float], int], - exposed_run_and_wait(user_credit_model.spend_credits), + empty_user_notification_batch = d.empty_user_notification_batch + get_all_batches_by_type = d.get_all_batches_by_type + get_user_notification_batch = d.get_user_notification_batch + get_user_notification_oldest_message_in_batch = ( + d.get_user_notification_oldest_message_in_batch ) - # User + User Metadata + User Integrations - get_user_metadata = exposed_run_and_wait(get_user_metadata) - update_user_metadata = exposed_run_and_wait(update_user_metadata) - get_user_integrations = exposed_run_and_wait(get_user_integrations) - update_user_integrations = exposed_run_and_wait(update_user_integrations) + # Library + list_library_agents = d.list_library_agents + add_store_agent_to_library = d.add_store_agent_to_library + + # Store + get_store_agents = d.get_store_agents + get_store_agent_details = d.get_store_agent_details + + # Summary data + get_user_execution_summary_data = d.get_user_execution_summary_data diff --git a/autogpt_platform/backend/backend/executor/manager.py b/autogpt_platform/backend/backend/executor/manager.py index 4dd2709c89d7..5e38c285d9fa 100644 --- a/autogpt_platform/backend/backend/executor/manager.py +++ b/autogpt_platform/backend/backend/executor/manager.py @@ -1,110 +1,138 @@ -import atexit +import asyncio import logging -import multiprocessing import os -import signal -import sys import threading -from concurrent.futures import Future, ProcessPoolExecutor -from contextlib import contextmanager -from multiprocessing.pool import AsyncResult, Pool -from typing import TYPE_CHECKING, Any, Generator, TypeVar, cast - -from pydantic import BaseModel -from redis.lock import Lock as RedisLock +import time +from collections import defaultdict +from concurrent.futures import Future, ThreadPoolExecutor +from contextlib import asynccontextmanager +from typing import TYPE_CHECKING, Any, Optional, TypeVar, cast + +from pika.adapters.blocking_connection import BlockingChannel +from pika.spec import Basic, BasicProperties +from pydantic import JsonValue +from redis.asyncio.lock import Lock as RedisLock + +from backend.blocks.io import AgentOutputBlock +from backend.data.model import GraphExecutionStats, NodeExecutionStats +from backend.data.notifications import ( + AgentRunData, + LowBalanceData, + NotificationEventModel, + NotificationType, + ZeroBalanceData, +) +from backend.data.rabbitmq import SyncRabbitMQ +from backend.executor.activity_status_generator import ( + generate_activity_status_for_execution, +) +from backend.executor.utils import LogMetadata +from backend.notifications.notifications import queue_notification +from backend.util.exceptions import InsufficientBalanceError, ModerationError if TYPE_CHECKING: - from backend.executor import DatabaseManager + from backend.executor import DatabaseManagerClient, DatabaseManagerAsyncClient -from autogpt_libs.utils.cache import thread_cached +from prometheus_client import Gauge, start_http_server from backend.blocks.agent import AgentExecutorBlock -from backend.data import redis -from backend.data.block import Block, BlockData, BlockInput, BlockType, get_block +from backend.data import redis_client as redis +from backend.data.block import ( + BlockData, + BlockInput, + BlockOutput, + BlockSchema, + get_block, +) +from backend.data.credit import UsageTransactionMetadata from backend.data.execution import ( ExecutionQueue, - ExecutionResult, ExecutionStatus, + GraphExecution, GraphExecutionEntry, NodeExecutionEntry, - merge_execution_input, + NodeExecutionResult, + UserContext, +) +from backend.data.graph import Link, Node +from backend.executor.utils import ( + GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS, + GRAPH_EXECUTION_CANCEL_QUEUE_NAME, + GRAPH_EXECUTION_QUEUE_NAME, + CancelExecutionEvent, + ExecutionOutputEntry, + NodeExecutionProgress, + block_usage_cost, + create_execution_queue_config, + execution_usage_cost, parse_execution_output, + validate_exec, ) -from backend.data.graph import GraphModel, Link, Node -from backend.data.model import CREDENTIALS_FIELD_NAME, CredentialsMetaInput from backend.integrations.creds_manager import IntegrationCredentialsManager +from backend.server.v2.AutoMod.manager import automod_manager from backend.util import json -from backend.util.decorator import error_logged, time_measured -from backend.util.logging import configure_logging -from backend.util.process import set_service_name -from backend.util.service import ( - AppService, - close_service_client, - expose, - get_service_client, +from backend.util.clients import ( + get_async_execution_event_bus, + get_database_manager_async_client, + get_database_manager_client, + get_execution_event_bus, + get_notification_manager_client, +) +from backend.util.decorator import ( + async_error_logged, + async_time_measured, + error_logged, + time_measured, ) +from backend.util.file import clean_exec_files +from backend.util.logging import TruncatedLogger, configure_logging +from backend.util.metrics import DiscordChannel +from backend.util.process import AppProcess, set_service_name +from backend.util.retry import continuous_retry, func_retry from backend.util.settings import Settings -from backend.util.type import convert -logger = logging.getLogger(__name__) +_logger = logging.getLogger(__name__) +logger = TruncatedLogger(_logger, prefix="[GraphExecutor]") settings = Settings() +active_runs_gauge = Gauge( + "execution_manager_active_runs", "Number of active graph runs" +) +pool_size_gauge = Gauge( + "execution_manager_pool_size", "Maximum number of graph workers" +) +utilization_gauge = Gauge( + "execution_manager_utilization_ratio", + "Ratio of active graph runs to max graph workers", +) -class LogMetadata: - def __init__( - self, - user_id: str, - graph_eid: str, - graph_id: str, - node_eid: str, - node_id: str, - block_name: str, - ): - self.metadata = { - "component": "ExecutionManager", - "user_id": user_id, - "graph_eid": graph_eid, - "graph_id": graph_id, - "node_eid": node_eid, - "node_id": node_id, - "block_name": block_name, - } - self.prefix = f"[ExecutionManager|uid:{user_id}|gid:{graph_id}|nid:{node_id}]|geid:{graph_eid}|nid:{node_eid}|{block_name}]" - - def info(self, msg: str, **extra): - msg = self._wrap(msg, **extra) - logger.info(msg, extra={"json_fields": {**self.metadata, **extra}}) - - def warning(self, msg: str, **extra): - msg = self._wrap(msg, **extra) - logger.warning(msg, extra={"json_fields": {**self.metadata, **extra}}) +# Thread-local storage for ExecutionProcessor instances +_tls = threading.local() - def error(self, msg: str, **extra): - msg = self._wrap(msg, **extra) - logger.error(msg, extra={"json_fields": {**self.metadata, **extra}}) - def debug(self, msg: str, **extra): - msg = self._wrap(msg, **extra) - logger.debug(msg, extra={"json_fields": {**self.metadata, **extra}}) +def init_worker(): + """Initialize ExecutionProcessor instance in thread-local storage""" + _tls.processor = ExecutionProcessor() + _tls.processor.on_graph_executor_start() - def exception(self, msg: str, **extra): - msg = self._wrap(msg, **extra) - logger.exception(msg, extra={"json_fields": {**self.metadata, **extra}}) - def _wrap(self, msg: str, **extra): - return f"{self.prefix} {msg} {extra}" +def execute_graph( + graph_exec_entry: "GraphExecutionEntry", cancel_event: threading.Event +): + """Execute graph using thread-local ExecutionProcessor instance""" + return _tls.processor.on_graph_execution(graph_exec_entry, cancel_event) T = TypeVar("T") -ExecutionStream = Generator[NodeExecutionEntry, None, None] -def execute_node( - db_client: "DatabaseManager", +async def execute_node( + node: Node, creds_manager: IntegrationCredentialsManager, data: NodeExecutionEntry, - execution_stats: dict[str, Any] | None = None, -) -> ExecutionStream: + execution_stats: NodeExecutionStats | None = None, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, +) -> BlockOutput: """ Execute a node in the graph. This will trigger a block execution on a node, persist the execution result, and return the subsequent node to be executed. @@ -123,20 +151,10 @@ def execute_node( graph_id = data.graph_id node_exec_id = data.node_exec_id node_id = data.node_id - - def update_execution(status: ExecutionStatus) -> ExecutionResult: - exec_update = db_client.update_execution_status(node_exec_id, status) - db_client.send_execution_update(exec_update) - return exec_update - - node = db_client.get_node(node_id) - - node_block = get_block(node.block_id) - if not node_block: - logger.error(f"Block {node.block_id} not found.") - return + node_block = node.block log_metadata = LogMetadata( + logger=_logger, user_id=user_id, graph_eid=graph_exec_id, graph_id=graph_id, @@ -146,152 +164,142 @@ def update_execution(status: ExecutionStatus) -> ExecutionResult: ) # Sanity check: validate the execution input. - input_data, error = validate_exec(node, data.data, resolve_input=False) + input_data, error = validate_exec(node, data.inputs, resolve_input=False) if input_data is None: log_metadata.error(f"Skip execution, input validation error: {error}") - db_client.upsert_execution_output(node_exec_id, "error", error) - update_execution(ExecutionStatus.FAILED) + yield "error", error return # Re-shape the input data for agent block. # AgentExecutorBlock specially separate the node input_data & its input_default. if isinstance(node_block, AgentExecutorBlock): - input_data = {**node.input_default, "data": input_data} + _input_data = AgentExecutorBlock.Input(**node.input_default) + _input_data.inputs = input_data + if nodes_input_masks: + _input_data.nodes_input_masks = nodes_input_masks + input_data = _input_data.model_dump() + data.inputs = input_data # Execute the node input_data_str = json.dumps(input_data) input_size = len(input_data_str) - log_metadata.info("Executed node with input", input=input_data_str) - update_execution(ExecutionStatus.RUNNING) + log_metadata.debug("Executed node with input", input=input_data_str) + + # Inject extra execution arguments for the blocks via kwargs + extra_exec_kwargs: dict = { + "graph_id": graph_id, + "node_id": node_id, + "graph_exec_id": graph_exec_id, + "node_exec_id": node_exec_id, + "user_id": user_id, + } + + # Add user context from NodeExecutionEntry + extra_exec_kwargs["user_context"] = data.user_context - extra_exec_kwargs = {} # Last-minute fetch credentials + acquire a system-wide read-write lock to prevent # changes during execution. ⚠️ This means a set of credentials can only be used by # one (running) block at a time; simultaneous execution of blocks using same # credentials is not supported. creds_lock = None - if CREDENTIALS_FIELD_NAME in input_data: - credentials_meta = CredentialsMetaInput(**input_data[CREDENTIALS_FIELD_NAME]) - credentials, creds_lock = creds_manager.acquire(user_id, credentials_meta.id) - extra_exec_kwargs["credentials"] = credentials + input_model = cast(type[BlockSchema], node_block.input_schema) + for field_name, input_type in input_model.get_credentials_fields().items(): + credentials_meta = input_type(**input_data[field_name]) + credentials, creds_lock = await creds_manager.acquire( + user_id, credentials_meta.id + ) + extra_exec_kwargs[field_name] = credentials output_size = 0 - end_status = ExecutionStatus.COMPLETED - credit = db_client.get_or_refill_credit(user_id) - if credit < 0: - raise ValueError(f"Insufficient credit: {credit}") - try: - for output_name, output_data in node_block.execute( + async for output_name, output_data in node_block.execute( input_data, **extra_exec_kwargs ): + output_data = json.convert_pydantic_to_json(output_data) output_size += len(json.dumps(output_data)) - log_metadata.info("Node produced output", **{output_name: output_data}) - db_client.upsert_execution_output(node_exec_id, output_name, output_data) - - for execution in _enqueue_next_nodes( - db_client=db_client, - node=node, - output=(output_name, output_data), - user_id=user_id, - graph_exec_id=graph_exec_id, - graph_id=graph_id, - log_metadata=log_metadata, - ): - yield execution - - except Exception as e: - end_status = ExecutionStatus.FAILED - error_msg = str(e) - log_metadata.exception(f"Node execution failed with error {error_msg}") - db_client.upsert_execution_output(node_exec_id, "error", error_msg) - - for execution in _enqueue_next_nodes( - db_client=db_client, - node=node, - output=("error", error_msg), - user_id=user_id, - graph_exec_id=graph_exec_id, - graph_id=graph_id, - log_metadata=log_metadata, - ): - yield execution - - raise e + log_metadata.debug("Node produced output", **{output_name: output_data}) + yield output_name, output_data finally: # Ensure credentials are released even if execution fails - if creds_lock: + if creds_lock and (await creds_lock.locked()) and (await creds_lock.owned()): try: - creds_lock.release() + await creds_lock.release() except Exception as e: log_metadata.error(f"Failed to release credentials lock: {e}") - # Update execution status and spend credits - res = update_execution(end_status) - if end_status == ExecutionStatus.COMPLETED: - s = input_size + output_size - t = ( - (res.end_time - res.start_time).total_seconds() - if res.end_time and res.start_time - else 0 - ) - db_client.spend_credits(user_id, credit, node_block.id, input_data, s, t) - # Update execution stats if execution_stats is not None: - execution_stats.update(node_block.execution_stats) - execution_stats["input_size"] = input_size - execution_stats["output_size"] = output_size + execution_stats += node_block.execution_stats + execution_stats.input_size = input_size + execution_stats.output_size = output_size -def _enqueue_next_nodes( - db_client: "DatabaseManager", +async def _enqueue_next_nodes( + db_client: "DatabaseManagerAsyncClient", node: Node, output: BlockData, user_id: str, graph_exec_id: str, graph_id: str, log_metadata: LogMetadata, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]], + user_context: UserContext, ) -> list[NodeExecutionEntry]: - def add_enqueued_execution( - node_exec_id: str, node_id: str, data: BlockInput + async def add_enqueued_execution( + node_exec_id: str, node_id: str, block_id: str, data: BlockInput ) -> NodeExecutionEntry: - exec_update = db_client.update_execution_status( - node_exec_id, ExecutionStatus.QUEUED, data + await async_update_node_execution_status( + db_client=db_client, + exec_id=node_exec_id, + status=ExecutionStatus.QUEUED, + execution_data=data, ) - db_client.send_execution_update(exec_update) return NodeExecutionEntry( user_id=user_id, graph_exec_id=graph_exec_id, graph_id=graph_id, node_exec_id=node_exec_id, node_id=node_id, - data=data, + block_id=block_id, + inputs=data, + user_context=user_context, ) - def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]: + async def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]: + try: + return await _register_next_executions(node_link) + except Exception as e: + log_metadata.exception(f"Failed to register next executions: {e}") + return [] + + async def _register_next_executions(node_link: Link) -> list[NodeExecutionEntry]: enqueued_executions = [] next_output_name = node_link.source_name next_input_name = node_link.sink_name next_node_id = node_link.sink_id + output_name, _ = output next_data = parse_execution_output(output, next_output_name) - if next_data is None: + if next_data is None and output_name != next_output_name: return enqueued_executions - - next_node = db_client.get_node(next_node_id) + next_node = await db_client.get_node(next_node_id) # Multiple node can register the same next node, we need this to be atomic # To avoid same execution to be enqueued multiple times, # Or the same input to be consumed multiple times. - with synchronized(f"upsert_input-{next_node_id}-{graph_exec_id}"): + async with synchronized(f"upsert_input-{next_node_id}-{graph_exec_id}"): # Add output data to the earliest incomplete execution, or create a new one. - next_node_exec_id, next_node_input = db_client.upsert_execution_input( + next_node_exec_id, next_node_input = await db_client.upsert_execution_input( node_id=next_node_id, graph_exec_id=graph_exec_id, input_name=next_input_name, input_data=next_data, ) + await async_update_node_execution_status( + db_client=db_client, + exec_id=next_node_exec_id, + status=ExecutionStatus.INCOMPLETE, + ) # Complete missing static input pins data using the last execution input. static_link_names = { @@ -300,13 +308,20 @@ def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]: if link.is_static and link.sink_name not in next_node_input } if static_link_names and ( - latest_execution := db_client.get_latest_execution( + latest_execution := await db_client.get_latest_node_execution( next_node_id, graph_exec_id ) ): for name in static_link_names: next_node_input[name] = latest_execution.input_data.get(name) + # Apply node input overrides + node_input_mask = None + if nodes_input_masks and ( + node_input_mask := nodes_input_masks.get(next_node.id) + ): + next_node_input.update(node_input_mask) + # Validate the input data for the next node. next_node_input, validation_msg = validate_exec(next_node, next_node_input) suffix = f"{next_output_name}>{next_input_name}~{next_node_exec_id}:{validation_msg}" @@ -319,7 +334,12 @@ def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]: # Input is complete, enqueue the execution. log_metadata.info(f"Enqueued {suffix}") enqueued_executions.append( - add_enqueued_execution(next_node_exec_id, next_node_id, next_node_input) + await add_enqueued_execution( + node_exec_id=next_node_exec_id, + node_id=next_node_id, + block_id=next_node.block_id, + data=next_node_input, + ) ) # Next execution stops here if the link is not static. @@ -328,8 +348,10 @@ def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]: # If link is static, there could be some incomplete executions waiting for it. # Load and complete the input missing input data, and try to re-enqueue them. - for iexec in db_client.get_incomplete_executions( - next_node_id, graph_exec_id + for iexec in await db_client.get_node_executions( + node_id=next_node_id, + graph_exec_id=graph_exec_id, + statuses=[ExecutionStatus.INCOMPLETE], ): idata = iexec.input_data ineid = iexec.node_exec_id @@ -342,6 +364,10 @@ def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]: for input_name in static_link_names: idata[input_name] = next_node_input[input_name] + # Apply node input overrides + if node_input_mask: + idata.update(node_input_mask) + idata, msg = validate_exec(next_node, idata) suffix = f"{next_output_name}>{next_input_name}~{ineid}:{msg}" if not idata: @@ -349,97 +375,30 @@ def register_next_executions(node_link: Link) -> list[NodeExecutionEntry]: continue log_metadata.info(f"Enqueueing static-link execution {suffix}") enqueued_executions.append( - add_enqueued_execution(iexec.node_exec_id, next_node_id, idata) + await add_enqueued_execution( + node_exec_id=iexec.node_exec_id, + node_id=next_node_id, + block_id=next_node.block_id, + data=idata, + ) ) return enqueued_executions return [ execution for link in node.output_links - for execution in register_next_executions(link) + for execution in await register_next_executions(link) ] -def validate_exec( - node: Node, - data: BlockInput, - resolve_input: bool = True, -) -> tuple[BlockInput | None, str]: - """ - Validate the input data for a node execution. - - Args: - node: The node to execute. - data: The input data for the node execution. - resolve_input: Whether to resolve dynamic pins into dict/list/object. - - Returns: - A tuple of the validated data and the block name. - If the data is invalid, the first element will be None, and the second element - will be an error message. - If the data is valid, the first element will be the resolved input data, and - the second element will be the block name. - """ - node_block: Block | None = get_block(node.block_id) - if not node_block: - return None, f"Block for {node.block_id} not found." - - if isinstance(node_block, AgentExecutorBlock): - # Validate the execution metadata for the agent executor block. - try: - exec_data = AgentExecutorBlock.Input(**node.input_default) - except Exception as e: - return None, f"Input data doesn't match {node_block.name}: {str(e)}" - - # Validation input - input_schema = exec_data.input_schema - required_fields = set(input_schema["required"]) - input_default = exec_data.data - else: - # Convert non-matching data types to the expected input schema. - for name, data_type in node_block.input_schema.__annotations__.items(): - if (value := data.get(name)) and (type(value) is not data_type): - data[name] = convert(value, data_type) - - # Validation input - input_schema = node_block.input_schema.jsonschema() - required_fields = node_block.input_schema.get_required_fields() - input_default = node.input_default - - # Input data (without default values) should contain all required fields. - error_prefix = f"Input data missing or mismatch for `{node_block.name}`:" - input_fields_from_nodes = {link.sink_name for link in node.input_links} - if not input_fields_from_nodes.issubset(data): - return None, f"{error_prefix} {input_fields_from_nodes - set(data)}" - - # Merge input data with default values and resolve dynamic dict/list/object pins. - data = {**input_default, **data} - if resolve_input: - data = merge_execution_input(data) - - # Input data post-merge should contain all required fields from the schema. - if not required_fields.issubset(data): - return None, f"{error_prefix} {required_fields - set(data)}" - - # Last validation: Validate the input values against the schema. - if error := json.validate_with_jsonschema(schema=input_schema, data=data): - error_message = f"{error_prefix} {error}" - logger.error(error_message) - return None, error_message - - return data, node_block.name - - -class Executor: +class ExecutionProcessor: """ This class contains event handlers for the process pool executor events. The main events are: - on_node_executor_start: Initialize the process that executes the node. - on_node_execution: Execution logic for a node. - on_graph_executor_start: Initialize the process that executes the graph. on_graph_execution: Execution logic for a graph. + on_node_execution: Execution logic for a node. The execution flow: 1. Graph execution request is added to the queue. @@ -455,56 +414,16 @@ class Executor: 9. Node executor enqueues the next executed nodes to the node execution queue. """ - @classmethod - def on_node_executor_start(cls): - configure_logging() - set_service_name("NodeExecutor") - redis.connect() - cls.pid = os.getpid() - cls.db_client = get_db_client() - cls.creds_manager = IntegrationCredentialsManager() - - # Set up shutdown handlers - cls.shutdown_lock = threading.Lock() - atexit.register(cls.on_node_executor_stop) # handle regular shutdown - signal.signal( # handle termination - signal.SIGTERM, lambda _, __: cls.on_node_executor_sigterm() - ) - - @classmethod - def on_node_executor_stop(cls): - if not cls.shutdown_lock.acquire(blocking=False): - return # already shutting down - - logger.info(f"[on_node_executor_stop {cls.pid}] ⏳ Releasing locks...") - cls.creds_manager.release_all_locks() - logger.info(f"[on_node_executor_stop {cls.pid}] ⏳ Disconnecting Redis...") - redis.disconnect() - logger.info(f"[on_node_executor_stop {cls.pid}] ⏳ Disconnecting DB manager...") - close_service_client(cls.db_client) - logger.info(f"[on_node_executor_stop {cls.pid}] ✅ Finished cleanup") - - @classmethod - def on_node_executor_sigterm(cls): - llprint(f"[on_node_executor_sigterm {cls.pid}] ⚠️ SIGTERM received") - if not cls.shutdown_lock.acquire(blocking=False): - return # already shutting down - - llprint(f"[on_node_executor_stop {cls.pid}] ⏳ Releasing locks...") - cls.creds_manager.release_all_locks() - llprint(f"[on_node_executor_stop {cls.pid}] ⏳ Disconnecting Redis...") - redis.disconnect() - llprint(f"[on_node_executor_stop {cls.pid}] ✅ Finished cleanup") - sys.exit(0) - - @classmethod - @error_logged - def on_node_execution( - cls, - q: ExecutionQueue[NodeExecutionEntry], + @async_error_logged(swallow=True) + async def on_node_execution( + self, node_exec: NodeExecutionEntry, - ) -> dict[str, Any]: + node_exec_progress: NodeExecutionProgress, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]], + graph_stats_pair: tuple[GraphExecutionStats, threading.Lock], + ) -> NodeExecutionStats: log_metadata = LogMetadata( + logger=_logger, user_id=node_exec.user_id, graph_eid=node_exec.graph_exec_id, graph_id=node_exec.graph_id, @@ -512,78 +431,161 @@ def on_node_execution( node_id=node_exec.node_id, block_name="-", ) + db_client = get_db_async_client() + node = await db_client.get_node(node_exec.node_id) + execution_stats = NodeExecutionStats() - execution_stats = {} - timing_info, _ = cls._on_node_execution( - q, node_exec, log_metadata, execution_stats + timing_info, status = await self._on_node_execution( + node=node, + node_exec=node_exec, + node_exec_progress=node_exec_progress, + stats=execution_stats, + db_client=db_client, + log_metadata=log_metadata, + nodes_input_masks=nodes_input_masks, ) - execution_stats["walltime"] = timing_info.wall_time - execution_stats["cputime"] = timing_info.cpu_time - - cls.db_client.update_node_execution_stats( - node_exec.node_exec_id, execution_stats + if isinstance(status, BaseException): + raise status + + execution_stats.walltime = timing_info.wall_time + execution_stats.cputime = timing_info.cpu_time + + graph_stats, graph_stats_lock = graph_stats_pair + with graph_stats_lock: + graph_stats.node_count += 1 + execution_stats.extra_steps + graph_stats.nodes_cputime += execution_stats.cputime + graph_stats.nodes_walltime += execution_stats.walltime + graph_stats.cost += execution_stats.extra_cost + if isinstance(execution_stats.error, Exception): + graph_stats.node_error_count += 1 + + node_error = execution_stats.error + node_stats = execution_stats.model_dump() + if node_error and not isinstance(node_error, str): + node_stats["error"] = str(node_error) or node_stats.__class__.__name__ + + await async_update_node_execution_status( + db_client=db_client, + exec_id=node_exec.node_exec_id, + status=status, + stats=node_stats, + ) + await async_update_graph_execution_state( + db_client=db_client, + graph_exec_id=node_exec.graph_exec_id, + stats=graph_stats, ) + return execution_stats - @classmethod - @time_measured - def _on_node_execution( - cls, - q: ExecutionQueue[NodeExecutionEntry], + @async_time_measured + async def _on_node_execution( + self, + node: Node, node_exec: NodeExecutionEntry, + node_exec_progress: NodeExecutionProgress, + stats: NodeExecutionStats, + db_client: "DatabaseManagerAsyncClient", log_metadata: LogMetadata, - stats: dict[str, Any] | None = None, - ): + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, + ) -> ExecutionStatus: + status = ExecutionStatus.RUNNING + + async def persist_output(output_name: str, output_data: Any) -> None: + await db_client.upsert_execution_output( + node_exec_id=node_exec.node_exec_id, + output_name=output_name, + output_data=output_data, + ) + if exec_update := await db_client.get_node_execution( + node_exec.node_exec_id + ): + await send_async_execution_update(exec_update) + + node_exec_progress.add_output( + ExecutionOutputEntry( + node=node, + node_exec_id=node_exec.node_exec_id, + data=(output_name, output_data), + ) + ) + try: log_metadata.info(f"Start node execution {node_exec.node_exec_id}") - for execution in execute_node( - cls.db_client, cls.creds_manager, node_exec, stats + await async_update_node_execution_status( + db_client=db_client, + exec_id=node_exec.node_exec_id, + status=ExecutionStatus.RUNNING, + ) + + async for output_name, output_data in execute_node( + node=node, + creds_manager=self.creds_manager, + data=node_exec, + execution_stats=stats, + nodes_input_masks=nodes_input_masks, ): - q.add(execution) + await persist_output(output_name, output_data) + log_metadata.info(f"Finished node execution {node_exec.node_exec_id}") - except Exception as e: - log_metadata.exception( - f"Failed node execution {node_exec.node_exec_id}: {e}" - ) + status = ExecutionStatus.COMPLETED + + except BaseException as e: + stats.error = e + + if isinstance(e, ValueError): + # Avoid user error being marked as an actual error. + log_metadata.info( + f"Expected failure on node execution {node_exec.node_exec_id}: {type(e).__name__} - {e}" + ) + status = ExecutionStatus.FAILED + elif isinstance(e, Exception): + # If the exception is not a ValueError, it is unexpected. + log_metadata.exception( + f"Unexpected failure on node execution {node_exec.node_exec_id}: {type(e).__name__} - {e}" + ) + status = ExecutionStatus.FAILED + else: + # CancelledError or SystemExit + log_metadata.warning( + f"Interruption error on node execution {node_exec.node_exec_id}: {type(e).__name__}" + ) + status = ExecutionStatus.TERMINATED + + finally: + if status == ExecutionStatus.FAILED and stats.error is not None: + await persist_output( + "error", str(stats.error) or type(stats.error).__name__ + ) + + return status - @classmethod - def on_graph_executor_start(cls): + @func_retry + def on_graph_executor_start(self): configure_logging() set_service_name("GraphExecutor") - - cls.db_client = get_db_client() - cls.pool_size = settings.config.num_node_workers - cls.pid = os.getpid() - cls._init_node_executor_pool() - logger.info( - f"Graph executor {cls.pid} started with {cls.pool_size} node workers" + self.tid = threading.get_ident() + self.creds_manager = IntegrationCredentialsManager() + self.node_execution_loop = asyncio.new_event_loop() + self.node_evaluation_loop = asyncio.new_event_loop() + self.node_execution_thread = threading.Thread( + target=self.node_execution_loop.run_forever, daemon=True ) - - # Set up shutdown handler - atexit.register(cls.on_graph_executor_stop) - - @classmethod - def on_graph_executor_stop(cls): - prefix = f"[on_graph_executor_stop {cls.pid}]" - logger.info(f"{prefix} ⏳ Terminating node executor pool...") - cls.executor.terminate() - logger.info(f"{prefix} ⏳ Disconnecting DB manager...") - close_service_client(cls.db_client) - logger.info(f"{prefix} ✅ Finished cleanup") - - @classmethod - def _init_node_executor_pool(cls): - cls.executor = Pool( - processes=cls.pool_size, - initializer=cls.on_node_executor_start, + self.node_evaluation_thread = threading.Thread( + target=self.node_evaluation_loop.run_forever, daemon=True ) + self.node_execution_thread.start() + self.node_evaluation_thread.start() + logger.info(f"[GraphExecutor] {self.tid} started") - @classmethod - @error_logged + @error_logged(swallow=False) def on_graph_execution( - cls, graph_exec: GraphExecutionEntry, cancel: threading.Event + self, + graph_exec: GraphExecutionEntry, + cancel: threading.Event, ): log_metadata = LogMetadata( + logger=_logger, user_id=graph_exec.user_id, graph_eid=graph_exec.graph_exec_id, graph_id=graph_exec.graph_id, @@ -591,369 +593,1100 @@ def on_graph_execution( node_eid="*", block_name="-", ) - timing_info, (exec_stats, error) = cls._on_graph_execution( - graph_exec, cancel, log_metadata + db_client = get_db_client() + + exec_meta = db_client.get_graph_execution_meta( + user_id=graph_exec.user_id, + execution_id=graph_exec.graph_exec_id, ) - exec_stats["walltime"] = timing_info.wall_time - exec_stats["cputime"] = timing_info.cpu_time - exec_stats["error"] = str(error) if error else None - result = cls.db_client.update_graph_execution_stats( - graph_exec_id=graph_exec.graph_exec_id, - stats=exec_stats, + if exec_meta is None: + log_metadata.warning( + f"Skipped graph execution #{graph_exec.graph_exec_id}, the graph execution is not found." + ) + return + + if exec_meta.status == ExecutionStatus.QUEUED: + log_metadata.info(f"⚙️ Starting graph execution #{graph_exec.graph_exec_id}") + exec_meta.status = ExecutionStatus.RUNNING + send_execution_update( + db_client.update_graph_execution_start_time(graph_exec.graph_exec_id) + ) + elif exec_meta.status == ExecutionStatus.RUNNING: + log_metadata.info( + f"⚙️ Graph execution #{graph_exec.graph_exec_id} is already running, continuing where it left off." + ) + elif exec_meta.status == ExecutionStatus.FAILED: + exec_meta.status = ExecutionStatus.RUNNING + log_metadata.info( + f"⚙️ Graph execution #{graph_exec.graph_exec_id} was disturbed, continuing where it left off." + ) + update_graph_execution_state( + db_client=db_client, + graph_exec_id=graph_exec.graph_exec_id, + status=ExecutionStatus.RUNNING, + ) + else: + log_metadata.warning( + f"Skipped graph execution {graph_exec.graph_exec_id}, the graph execution status is `{exec_meta.status}`." + ) + return + + if exec_meta.stats is None: + exec_stats = GraphExecutionStats() + else: + exec_stats = exec_meta.stats.to_db() + + timing_info, status = self._on_graph_execution( + graph_exec=graph_exec, + cancel=cancel, + log_metadata=log_metadata, + execution_stats=exec_stats, ) - cls.db_client.send_execution_update(result) + exec_stats.walltime += timing_info.wall_time + exec_stats.cputime += timing_info.cpu_time + + try: + # Failure handling + if isinstance(status, BaseException): + raise status + exec_meta.status = status + + # Activity status handling + activity_status = asyncio.run_coroutine_threadsafe( + generate_activity_status_for_execution( + graph_exec_id=graph_exec.graph_exec_id, + graph_id=graph_exec.graph_id, + graph_version=graph_exec.graph_version, + execution_stats=exec_stats, + db_client=get_db_async_client(), + user_id=graph_exec.user_id, + execution_status=status, + ), + self.node_execution_loop, + ).result(timeout=60.0) + if activity_status is not None: + exec_stats.activity_status = activity_status + log_metadata.info(f"Generated activity status: {activity_status}") + else: + log_metadata.debug( + "Activity status generation disabled, not setting field" + ) + + # Communication handling + self._handle_agent_run_notif(db_client, graph_exec, exec_stats) + + finally: + update_graph_execution_state( + db_client=db_client, + graph_exec_id=graph_exec.graph_exec_id, + status=exec_meta.status, + stats=exec_stats, + ) + + def _charge_usage( + self, + node_exec: NodeExecutionEntry, + execution_count: int, + ) -> tuple[int, int]: + total_cost = 0 + remaining_balance = 0 + db_client = get_db_client() + block = get_block(node_exec.block_id) + if not block: + logger.error(f"Block {node_exec.block_id} not found.") + return total_cost, 0 + + cost, matching_filter = block_usage_cost( + block=block, input_data=node_exec.inputs + ) + if cost > 0: + remaining_balance = db_client.spend_credits( + user_id=node_exec.user_id, + cost=cost, + metadata=UsageTransactionMetadata( + graph_exec_id=node_exec.graph_exec_id, + graph_id=node_exec.graph_id, + node_exec_id=node_exec.node_exec_id, + node_id=node_exec.node_id, + block_id=node_exec.block_id, + block=block.name, + input=matching_filter, + reason=f"Ran block {node_exec.block_id} {block.name}", + ), + ) + total_cost += cost + + cost, usage_count = execution_usage_cost(execution_count) + if cost > 0: + remaining_balance = db_client.spend_credits( + user_id=node_exec.user_id, + cost=cost, + metadata=UsageTransactionMetadata( + graph_exec_id=node_exec.graph_exec_id, + graph_id=node_exec.graph_id, + input={ + "execution_count": usage_count, + "charge": "Execution Cost", + }, + reason=f"Execution Cost for {usage_count} blocks of ex_id:{node_exec.graph_exec_id} g_id:{node_exec.graph_id}", + ), + ) + total_cost += cost + + return total_cost, remaining_balance - @classmethod @time_measured def _on_graph_execution( - cls, + self, graph_exec: GraphExecutionEntry, cancel: threading.Event, log_metadata: LogMetadata, - ) -> tuple[dict[str, Any], Exception | None]: + execution_stats: GraphExecutionStats, + ) -> ExecutionStatus: """ Returns: - The execution statistics of the graph execution. - The error that occurred during the execution. + dict: The execution statistics of the graph execution. + ExecutionStatus: The final status of the graph execution. + Exception | None: The error that occurred during the execution, if any. """ - log_metadata.info(f"Start graph execution {graph_exec.graph_exec_id}") - exec_stats = { - "nodes_walltime": 0, - "nodes_cputime": 0, - "node_count": 0, - } - error = None - finished = False - - def cancel_handler(): - while not cancel.is_set(): - cancel.wait(1) - if finished: - return - cls.executor.terminate() - log_metadata.info(f"Terminated graph execution {graph_exec.graph_exec_id}") - cls._init_node_executor_pool() - - cancel_thread = threading.Thread(target=cancel_handler) - cancel_thread.start() + execution_status: ExecutionStatus = ExecutionStatus.RUNNING + error: Exception | None = None + db_client = get_db_client() + execution_stats_lock = threading.Lock() + + # State holders ---------------------------------------------------- + running_node_execution: dict[str, NodeExecutionProgress] = defaultdict( + NodeExecutionProgress + ) + running_node_evaluation: dict[str, Future] = {} + execution_queue = ExecutionQueue[NodeExecutionEntry]() try: - queue = ExecutionQueue[NodeExecutionEntry]() - for node_exec in graph_exec.start_node_execs: - queue.add(node_exec) - - running_executions: dict[str, AsyncResult] = {} - - def make_exec_callback(exec_data: NodeExecutionEntry): - node_id = exec_data.node_id - - def callback(result: object): - running_executions.pop(node_id) - nonlocal exec_stats - if isinstance(result, dict): - exec_stats["node_count"] += 1 - exec_stats["nodes_cputime"] += result.get("cputime", 0) - exec_stats["nodes_walltime"] += result.get("walltime", 0) + if db_client.get_credits(graph_exec.user_id) <= 0: + raise InsufficientBalanceError( + user_id=graph_exec.user_id, + message="You have no credits left to run an agent.", + balance=0, + amount=1, + ) - return callback + # Input moderation + try: + if moderation_error := asyncio.run_coroutine_threadsafe( + automod_manager.moderate_graph_execution_inputs( + db_client=get_db_async_client(), + graph_exec=graph_exec, + ), + self.node_evaluation_loop, + ).result(timeout=30.0): + raise moderation_error + except asyncio.TimeoutError: + log_metadata.warning( + f"Input moderation timed out for graph execution {graph_exec.graph_exec_id}, bypassing moderation and continuing execution" + ) + # Continue execution without moderation + + # ------------------------------------------------------------ + # Pre‑populate queue --------------------------------------- + # ------------------------------------------------------------ + for node_exec in db_client.get_node_executions( + graph_exec.graph_exec_id, + statuses=[ + ExecutionStatus.RUNNING, + ExecutionStatus.QUEUED, + ExecutionStatus.TERMINATED, + ], + ): + node_entry = node_exec.to_node_execution_entry(graph_exec.user_context) + execution_queue.add(node_entry) - while not queue.empty(): + # ------------------------------------------------------------ + # Main dispatch / polling loop ----------------------------- + # ------------------------------------------------------------ + while not execution_queue.empty(): if cancel.is_set(): - error = RuntimeError("Execution is cancelled") - return exec_stats, error - - exec_data = queue.get() + break - # Avoid parallel execution of the same node. - execution = running_executions.get(exec_data.node_id) - if execution and not execution.ready(): - # TODO (performance improvement): - # Wait for the completion of the same node execution is blocking. - # To improve this we need a separate queue for each node. - # Re-enqueueing the data back to the queue will disrupt the order. - execution.wait() + queued_node_exec = execution_queue.get() log_metadata.debug( - f"Dispatching node execution {exec_data.node_exec_id} " - f"for node {exec_data.node_id}", - ) - running_executions[exec_data.node_id] = cls.executor.apply_async( - cls.on_node_execution, - (queue, exec_data), - callback=make_exec_callback(exec_data), + f"Dispatching node execution {queued_node_exec.node_exec_id} " + f"for node {queued_node_exec.node_id}", ) - # Avoid terminating graph execution when some nodes are still running. - while queue.empty() and running_executions: - log_metadata.debug( - f"Queue empty; running nodes: {list(running_executions.keys())}" + # Charge usage (may raise) ------------------------------ + try: + cost, remaining_balance = self._charge_usage( + node_exec=queued_node_exec, + execution_count=increment_execution_count(graph_exec.user_id), + ) + with execution_stats_lock: + execution_stats.cost += cost + # Check if we crossed the low balance threshold + self._handle_low_balance( + db_client=db_client, + user_id=graph_exec.user_id, + current_balance=remaining_balance, + transaction_cost=cost, + ) + except InsufficientBalanceError as balance_error: + error = balance_error # Set error to trigger FAILED status + node_exec_id = queued_node_exec.node_exec_id + db_client.upsert_execution_output( + node_exec_id=node_exec_id, + output_name="error", + output_data=str(error), + ) + update_node_execution_status( + db_client=db_client, + exec_id=node_exec_id, + status=ExecutionStatus.FAILED, + ) + + self._handle_insufficient_funds_notif( + db_client, + graph_exec.user_id, + graph_exec.graph_id, + error, ) - for node_id, execution in list(running_executions.items()): + # Gracefully stop the execution loop + break + + # Add input overrides ----------------------------- + node_id = queued_node_exec.node_id + if (nodes_input_masks := graph_exec.nodes_input_masks) and ( + node_input_mask := nodes_input_masks.get(node_id) + ): + queued_node_exec.inputs.update(node_input_mask) + + # Kick off async node execution ------------------------- + node_execution_task = asyncio.run_coroutine_threadsafe( + self.on_node_execution( + node_exec=queued_node_exec, + node_exec_progress=running_node_execution[node_id], + nodes_input_masks=nodes_input_masks, + graph_stats_pair=( + execution_stats, + execution_stats_lock, + ), + ), + self.node_execution_loop, + ) + running_node_execution[node_id].add_task( + node_exec_id=queued_node_exec.node_exec_id, + task=node_execution_task, + ) + + # Poll until queue refills or all inflight work done ---- + while execution_queue.empty() and ( + running_node_execution or running_node_evaluation + ): + if cancel.is_set(): + break + + # -------------------------------------------------- + # Handle inflight evaluations --------------------- + # -------------------------------------------------- + node_output_found = False + for node_id, inflight_exec in list(running_node_execution.items()): if cancel.is_set(): - error = RuntimeError("Execution is cancelled") - return exec_stats, error + break + + # node evaluation future ----------------- + if inflight_eval := running_node_evaluation.get(node_id): + if not inflight_eval.done(): + continue + try: + inflight_eval.result(timeout=0) + running_node_evaluation.pop(node_id) + except Exception as e: + log_metadata.error(f"Node eval #{node_id} failed: {e}") + + # node execution future --------------------------- + if inflight_exec.is_done(): + running_node_execution.pop(node_id) + continue + + if output := inflight_exec.pop_output(): + node_output_found = True + running_node_evaluation[node_id] = ( + asyncio.run_coroutine_threadsafe( + self._process_node_output( + output=output, + node_id=node_id, + graph_exec=graph_exec, + log_metadata=log_metadata, + nodes_input_masks=nodes_input_masks, + execution_queue=execution_queue, + ), + self.node_evaluation_loop, + ) + ) + if ( + not node_output_found + and execution_queue.empty() + and (running_node_execution or running_node_evaluation) + ): + # There is nothing to execute, and no output to process, let's relax for a while. + time.sleep(0.1) + + # loop done -------------------------------------------------- + + # Output moderation + try: + if moderation_error := asyncio.run_coroutine_threadsafe( + automod_manager.moderate_graph_execution_outputs( + db_client=get_db_async_client(), + graph_exec_id=graph_exec.graph_exec_id, + user_id=graph_exec.user_id, + graph_id=graph_exec.graph_id, + ), + self.node_evaluation_loop, + ).result(timeout=30.0): + raise moderation_error + except asyncio.TimeoutError: + log_metadata.warning( + f"Output moderation timed out for graph execution {graph_exec.graph_exec_id}, bypassing moderation and continuing execution" + ) + # Continue execution without moderation + + # Determine final execution status based on whether there was an error or termination + if cancel.is_set(): + execution_status = ExecutionStatus.TERMINATED + elif error is not None: + execution_status = ExecutionStatus.FAILED + else: + execution_status = ExecutionStatus.COMPLETED - if not queue.empty(): - break # yield to parent loop to execute new queue items + if error: + execution_stats.error = str(error) or type(error).__name__ - log_metadata.debug(f"Waiting on execution of node {node_id}") - execution.wait(3) + return execution_status - log_metadata.info(f"Finished graph execution {graph_exec.graph_exec_id}") - except Exception as e: + except BaseException as e: + error = ( + e + if isinstance(e, Exception) + else Exception(f"{e.__class__.__name__}: {e}") + ) + + known_errors = (InsufficientBalanceError, ModerationError) + if isinstance(error, known_errors): + execution_stats.error = str(error) + return ExecutionStatus.FAILED + + execution_status = ExecutionStatus.FAILED log_metadata.exception( - f"Failed graph execution {graph_exec.graph_exec_id}: {e}" + f"Failed graph execution {graph_exec.graph_exec_id}: {error}" ) - error = e + raise + finally: - if not cancel.is_set(): - finished = True - cancel.set() - cancel_thread.join() - return exec_stats, error + self._cleanup_graph_execution( + execution_queue=execution_queue, + running_node_execution=running_node_execution, + running_node_evaluation=running_node_evaluation, + execution_status=execution_status, + error=error, + graph_exec_id=graph_exec.graph_exec_id, + log_metadata=log_metadata, + db_client=db_client, + ) + + @error_logged(swallow=True) + def _cleanup_graph_execution( + self, + execution_queue: ExecutionQueue[NodeExecutionEntry], + running_node_execution: dict[str, "NodeExecutionProgress"], + running_node_evaluation: dict[str, Future], + execution_status: ExecutionStatus, + error: Exception | None, + graph_exec_id: str, + log_metadata: LogMetadata, + db_client: "DatabaseManagerClient", + ) -> None: + """ + Clean up running node executions and evaluations when graph execution ends. + This method is decorated with @error_logged(swallow=True) to ensure cleanup + never fails in the finally block. + """ + # Cancel and wait for all node executions to complete + for node_id, inflight_exec in running_node_execution.items(): + if inflight_exec.is_done(): + continue + log_metadata.info(f"Stopping node execution {node_id}") + inflight_exec.stop() + + for node_id, inflight_exec in running_node_execution.items(): + try: + inflight_exec.wait_for_done(timeout=3600.0) + except TimeoutError: + log_metadata.exception( + f"Node execution #{node_id} did not stop in time, " + "it may be stuck or taking too long." + ) + + # Wait the remaining inflight evaluations to finish + for node_id, inflight_eval in running_node_evaluation.items(): + try: + inflight_eval.result(timeout=3600.0) + except TimeoutError: + log_metadata.exception( + f"Node evaluation #{node_id} did not stop in time, " + "it may be stuck or taking too long." + ) + + while queued_execution := execution_queue.get_or_none(): + update_node_execution_status( + db_client=db_client, + exec_id=queued_execution.node_exec_id, + status=execution_status, + stats={"error": str(error)} if error else None, + ) + + clean_exec_files(graph_exec_id) + + @async_error_logged(swallow=True) + async def _process_node_output( + self, + output: ExecutionOutputEntry, + node_id: str, + graph_exec: GraphExecutionEntry, + log_metadata: LogMetadata, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]], + execution_queue: ExecutionQueue[NodeExecutionEntry], + ) -> None: + """Process a node's output, update its status, and enqueue next nodes. + + Args: + output: The execution output entry to process + node_id: The ID of the node that produced the output + graph_exec: The graph execution entry + log_metadata: Logger metadata for consistent logging + nodes_input_masks: Optional map of node input overrides + execution_queue: Queue to add next executions to + """ + db_client = get_db_async_client() + + log_metadata.debug(f"Enqueue nodes for {node_id}: {output}") + + for next_execution in await _enqueue_next_nodes( + db_client=db_client, + node=output.node, + output=output.data, + user_id=graph_exec.user_id, + graph_exec_id=graph_exec.graph_exec_id, + graph_id=graph_exec.graph_id, + log_metadata=log_metadata, + nodes_input_masks=nodes_input_masks, + user_context=graph_exec.user_context, + ): + execution_queue.add(next_execution) + + def _handle_agent_run_notif( + self, + db_client: "DatabaseManagerClient", + graph_exec: GraphExecutionEntry, + exec_stats: GraphExecutionStats, + ): + metadata = db_client.get_graph_metadata( + graph_exec.graph_id, graph_exec.graph_version + ) + outputs = db_client.get_node_executions( + graph_exec.graph_exec_id, + block_ids=[AgentOutputBlock().id], + ) + + named_outputs = [ + { + key: value[0] if key == "name" else value + for key, value in output.output_data.items() + } + for output in outputs + ] + + queue_notification( + NotificationEventModel( + user_id=graph_exec.user_id, + type=NotificationType.AGENT_RUN, + data=AgentRunData( + outputs=named_outputs, + agent_name=metadata.name if metadata else "Unknown Agent", + credits_used=exec_stats.cost, + execution_time=exec_stats.walltime, + graph_id=graph_exec.graph_id, + node_count=exec_stats.node_count, + ), + ) + ) + + def _handle_insufficient_funds_notif( + self, + db_client: "DatabaseManagerClient", + user_id: str, + graph_id: str, + e: InsufficientBalanceError, + ): + shortfall = abs(e.amount) - e.balance + metadata = db_client.get_graph_metadata(graph_id) + base_url = ( + settings.config.frontend_base_url or settings.config.platform_base_url + ) + queue_notification( + NotificationEventModel( + user_id=user_id, + type=NotificationType.ZERO_BALANCE, + data=ZeroBalanceData( + current_balance=e.balance, + billing_page_link=f"{base_url}/profile/credits", + shortfall=shortfall, + agent_name=metadata.name if metadata else "Unknown Agent", + ), + ) + ) + + try: + user_email = db_client.get_user_email_by_id(user_id) + + alert_message = ( + f"❌ **Insufficient Funds Alert**\n" + f"User: {user_email or user_id}\n" + f"Agent: {metadata.name if metadata else 'Unknown Agent'}\n" + f"Current balance: ${e.balance/100:.2f}\n" + f"Attempted cost: ${abs(e.amount)/100:.2f}\n" + f"Shortfall: ${abs(shortfall)/100:.2f}\n" + f"[View User Details]({base_url}/admin/spending?search={user_email})" + ) + + get_notification_manager_client().discord_system_alert( + alert_message, DiscordChannel.PRODUCT + ) + except Exception as alert_error: + logger.error( + f"Failed to send insufficient funds Discord alert: {alert_error}" + ) + + def _handle_low_balance( + self, + db_client: "DatabaseManagerClient", + user_id: str, + current_balance: int, + transaction_cost: int, + ): + """Check and handle low balance scenarios after a transaction""" + LOW_BALANCE_THRESHOLD = settings.config.low_balance_threshold + + balance_before = current_balance + transaction_cost + + if ( + current_balance < LOW_BALANCE_THRESHOLD + and balance_before >= LOW_BALANCE_THRESHOLD + ): + base_url = ( + settings.config.frontend_base_url or settings.config.platform_base_url + ) + queue_notification( + NotificationEventModel( + user_id=user_id, + type=NotificationType.LOW_BALANCE, + data=LowBalanceData( + current_balance=current_balance, + billing_page_link=f"{base_url}/profile/credits", + ), + ) + ) + + try: + user_email = db_client.get_user_email_by_id(user_id) + alert_message = ( + f"⚠️ **Low Balance Alert**\n" + f"User: {user_email or user_id}\n" + f"Balance dropped below ${LOW_BALANCE_THRESHOLD/100:.2f}\n" + f"Current balance: ${current_balance/100:.2f}\n" + f"Transaction cost: ${transaction_cost/100:.2f}\n" + f"[View User Details]({base_url}/admin/spending?search={user_email})" + ) + get_notification_manager_client().discord_system_alert( + alert_message, DiscordChannel.PRODUCT + ) + except Exception as e: + logger.error(f"Failed to send low balance Discord alert: {e}") -class ExecutionManager(AppService): + +class ExecutionManager(AppProcess): def __init__(self): super().__init__() - self.use_redis = True - self.use_supabase = True self.pool_size = settings.config.num_graph_workers - self.queue = ExecutionQueue[GraphExecutionEntry]() self.active_graph_runs: dict[str, tuple[Future, threading.Event]] = {} - @classmethod - def get_port(cls) -> int: - return settings.config.execution_manager_port + self._executor = None + self._stop_consuming = None - def run_service(self): - from backend.integrations.credentials_store import IntegrationCredentialsStore + self._cancel_thread = None + self._cancel_client = None + self._run_thread = None + self._run_client = None - self.credentials_store = IntegrationCredentialsStore() - self.executor = ProcessPoolExecutor( - max_workers=self.pool_size, - initializer=Executor.on_graph_executor_start, - ) - sync_manager = multiprocessing.Manager() - logger.info( - f"[{self.service_name}] Started with max-{self.pool_size} graph workers" - ) - while True: - graph_exec_data = self.queue.get() - graph_exec_id = graph_exec_data.graph_exec_id - logger.debug( - f"[ExecutionManager] Dispatching graph execution {graph_exec_id}" - ) - cancel_event = sync_manager.Event() - future = self.executor.submit( - Executor.on_graph_execution, graph_exec_data, cancel_event + @property + def cancel_thread(self) -> threading.Thread: + if self._cancel_thread is None: + self._cancel_thread = threading.Thread( + target=lambda: self._consume_execution_cancel(), + daemon=True, ) - self.active_graph_runs[graph_exec_id] = (future, cancel_event) - future.add_done_callback( - lambda _: self.active_graph_runs.pop(graph_exec_id, None) + return self._cancel_thread + + @property + def run_thread(self) -> threading.Thread: + if self._run_thread is None: + self._run_thread = threading.Thread( + target=lambda: self._consume_execution_run(), + daemon=True, ) + return self._run_thread - def cleanup(self): - logger.info(f"[{__class__.__name__}] ⏳ Shutting down graph executor pool...") - self.executor.shutdown(cancel_futures=True) + @property + def stop_consuming(self) -> threading.Event: + if self._stop_consuming is None: + self._stop_consuming = threading.Event() + return self._stop_consuming - super().cleanup() + @property + def executor(self) -> ThreadPoolExecutor: + if self._executor is None: + self._executor = ThreadPoolExecutor( + max_workers=self.pool_size, + initializer=init_worker, + ) + return self._executor @property - def db_client(self) -> "DatabaseManager": - return get_db_client() + def cancel_client(self) -> SyncRabbitMQ: + if self._cancel_client is None: + self._cancel_client = SyncRabbitMQ(create_execution_queue_config()) + return self._cancel_client - @expose - def add_execution( - self, - graph_id: str, - data: BlockInput, - user_id: str, - graph_version: int | None = None, - ) -> GraphExecutionEntry: - graph: GraphModel | None = self.db_client.get_graph( - graph_id=graph_id, user_id=user_id, version=graph_version - ) - if not graph: - raise ValueError(f"Graph #{graph_id} not found.") + @property + def run_client(self) -> SyncRabbitMQ: + if self._run_client is None: + self._run_client = SyncRabbitMQ(create_execution_queue_config()) + return self._run_client - graph.validate_graph(for_run=True) - self._validate_node_input_credentials(graph, user_id) + def run(self): + logger.info(f"[{self.service_name}] ⏳ Spawn max-{self.pool_size} workers...") - nodes_input = [] - for node in graph.starting_nodes: - input_data = {} - block = get_block(node.block_id) + pool_size_gauge.set(self.pool_size) + self._update_prompt_metrics() + start_http_server(settings.config.execution_manager_port) - # Invalid block & Note block should never be executed. - if not block or block.block_type == BlockType.NOTE: - continue + self.cancel_thread.start() + self.run_thread.start() - # Extract request input data, and assign it to the input pin. - if block.block_type == BlockType.INPUT: - name = node.input_default.get("name") - if name and name in data: - input_data = {"value": data[name]} - - # Extract webhook payload, and assign it to the input pin - webhook_payload_key = f"webhook_{node.webhook_id}_payload" - if ( - block.block_type in (BlockType.WEBHOOK, BlockType.WEBHOOK_MANUAL) - and node.webhook_id - ): - if webhook_payload_key not in data: - raise ValueError( - f"Node {block.name} #{node.id} webhook payload is missing" - ) - input_data = {"payload": data[webhook_payload_key]} + while True: + time.sleep(1e5) - input_data, error = validate_exec(node, input_data) - if input_data is None: - raise ValueError(error) - else: - nodes_input.append((node.id, input_data)) + @continuous_retry() + def _consume_execution_cancel(self): + if self.stop_consuming.is_set() and not self.active_graph_runs: + logger.info( + f"[{self.service_name}] Stop reconnecting cancel consumer since the service is cleaned up." + ) + return - graph_exec_id, node_execs = self.db_client.create_graph_execution( - graph_id=graph_id, - graph_version=graph.version, - nodes_input=nodes_input, - user_id=user_id, + # Check if channel is closed and force reconnection if needed + if not self.cancel_client.is_ready: + self.cancel_client.disconnect() + self.cancel_client.connect() + cancel_channel = self.cancel_client.get_channel() + cancel_channel.basic_consume( + queue=GRAPH_EXECUTION_CANCEL_QUEUE_NAME, + on_message_callback=self._handle_cancel_message, + auto_ack=True, ) - - starting_node_execs = [] - for node_exec in node_execs: - starting_node_execs.append( - NodeExecutionEntry( - user_id=user_id, - graph_exec_id=node_exec.graph_exec_id, - graph_id=node_exec.graph_id, - node_exec_id=node_exec.node_exec_id, - node_id=node_exec.node_id, - data=node_exec.input_data, - ) + logger.info(f"[{self.service_name}] ⏳ Starting cancel message consumer...") + cancel_channel.start_consuming() + if not self.stop_consuming.is_set() or self.active_graph_runs: + raise RuntimeError( + f"[{self.service_name}] ❌ cancel message consumer is stopped: {cancel_channel}" ) - exec_update = self.db_client.update_execution_status( - node_exec.node_exec_id, ExecutionStatus.QUEUED, node_exec.input_data + logger.info( + f"[{self.service_name}] ✅ Cancel message consumer stopped gracefully" + ) + + @continuous_retry() + def _consume_execution_run(self): + # Long-running executions are handled by: + # 1. Long consumer timeout (x-consumer-timeout) allows long running agent + # 2. Enhanced connection settings (5 retries, 1s delay) for quick reconnection + # 3. Process monitoring ensures failed executors release messages back to queue + if self.stop_consuming.is_set(): + logger.info( + f"[{self.service_name}] Stop reconnecting execution consumer since the service is cleaned up." ) - self.db_client.send_execution_update(exec_update) + return - graph_exec = GraphExecutionEntry( - user_id=user_id, - graph_id=graph_id, - graph_exec_id=graph_exec_id, - start_node_execs=starting_node_execs, + # Check if channel is closed and force reconnection if needed + if not self.run_client.is_ready: + self.run_client.disconnect() + self.run_client.connect() + run_channel = self.run_client.get_channel() + run_channel.basic_qos(prefetch_count=self.pool_size) + + # Configure consumer for long-running graph executions + # auto_ack=False: Don't acknowledge messages until execution completes (prevents data loss) + run_channel.basic_consume( + queue=GRAPH_EXECUTION_QUEUE_NAME, + on_message_callback=self._handle_run_message, + auto_ack=False, + consumer_tag="graph_execution_consumer", ) - self.queue.add(graph_exec) - - return graph_exec + run_channel.confirm_delivery() + logger.info(f"[{self.service_name}] ⏳ Starting to consume run messages...") + run_channel.start_consuming() + if not self.stop_consuming.is_set(): + raise RuntimeError( + f"[{self.service_name}] ❌ run message consumer is stopped: {run_channel}" + ) + logger.info(f"[{self.service_name}] ✅ Run message consumer stopped gracefully") - @expose - def cancel_execution(self, graph_exec_id: str) -> None: + @error_logged(swallow=True) + def _handle_cancel_message( + self, + _channel: BlockingChannel, + _method: Basic.Deliver, + _properties: BasicProperties, + body: bytes, + ): """ - Mechanism: - 1. Set the cancel event - 2. Graph executor's cancel handler thread detects the event, terminates workers, - reinitializes worker pool, and returns. - 3. Update execution statuses in DB and set `error` outputs to `"TERMINATED"`. + Called whenever we receive a CANCEL message from the queue. + (With auto_ack=True, message is considered 'acked' automatically.) """ + request = CancelExecutionEvent.model_validate_json(body) + graph_exec_id = request.graph_exec_id + if not graph_exec_id: + logger.warning( + f"[{self.service_name}] Cancel message missing 'graph_exec_id'" + ) + return if graph_exec_id not in self.active_graph_runs: - raise Exception( - f"Graph execution #{graph_exec_id} not active/running: " - "possibly already completed/cancelled." + logger.debug( + f"[{self.service_name}] Cancel received for {graph_exec_id} but not active." ) - - future, cancel_event = self.active_graph_runs[graph_exec_id] - if cancel_event.is_set(): return - cancel_event.set() - future.result() + _, cancel_event = self.active_graph_runs[graph_exec_id] + logger.info(f"[{self.service_name}] Received cancel for {graph_exec_id}") + if not cancel_event.is_set(): + cancel_event.set() + else: + logger.debug( + f"[{self.service_name}] Cancel already set for {graph_exec_id}" + ) - # Update the status of the unfinished node executions - node_execs = self.db_client.get_execution_results(graph_exec_id) - for node_exec in node_execs: - if node_exec.status not in ( - ExecutionStatus.COMPLETED, - ExecutionStatus.FAILED, - ): - self.db_client.upsert_execution_output( - node_exec.node_exec_id, "error", "TERMINATED" - ) - exec_update = self.db_client.update_execution_status( - node_exec.node_exec_id, ExecutionStatus.FAILED - ) - self.db_client.send_execution_update(exec_update) + def _handle_run_message( + self, + _channel: BlockingChannel, + method: Basic.Deliver, + _properties: BasicProperties, + body: bytes, + ): + delivery_tag = method.delivery_tag - def _validate_node_input_credentials(self, graph: GraphModel, user_id: str): - """Checks all credentials for all nodes of the graph""" + @func_retry + def _ack_message(reject: bool, requeue: bool): + """Acknowledge or reject the message based on execution status.""" - for node in graph.nodes: - block = get_block(node.block_id) - if not block: - raise ValueError(f"Unknown block {node.block_id} for node #{node.id}") + # Connection can be lost, so always get a fresh channel + channel = self.run_client.get_channel() + if reject: + channel.connection.add_callback_threadsafe( + lambda: channel.basic_nack(delivery_tag, requeue=requeue) + ) + else: + channel.connection.add_callback_threadsafe( + lambda: channel.basic_ack(delivery_tag) + ) - # Find any fields of type CredentialsMetaInput - model_fields = cast(type[BaseModel], block.input_schema).model_fields - if CREDENTIALS_FIELD_NAME not in model_fields: - continue + # Check if we're shutting down - reject new messages but keep connection alive + if self.stop_consuming.is_set(): + logger.info( + f"[{self.service_name}] Rejecting new execution during shutdown" + ) + _ack_message(reject=True, requeue=True) + return - field = model_fields[CREDENTIALS_FIELD_NAME] + # Check if we can accept more runs + self._cleanup_completed_runs() + if len(self.active_graph_runs) >= self.pool_size: + _ack_message(reject=True, requeue=True) + return - # The BlockSchema class enforces that a `credentials` field is always a - # `CredentialsMetaInput`, so we can safely assume this here. - credentials_meta_type = cast(CredentialsMetaInput, field.annotation) - credentials_meta = credentials_meta_type.model_validate( - node.input_default[CREDENTIALS_FIELD_NAME] + try: + graph_exec_entry = GraphExecutionEntry.model_validate_json(body) + except Exception as e: + logger.error( + f"[{self.service_name}] Could not parse run message: {e}, body={body}" ) - # Fetch the corresponding Credentials and perform sanity checks - credentials = self.credentials_store.get_creds_by_id( - user_id, credentials_meta.id + _ack_message(reject=True, requeue=False) + return + + graph_exec_id = graph_exec_entry.graph_exec_id + logger.info( + f"[{self.service_name}] Received RUN for graph_exec_id={graph_exec_id}" + ) + if graph_exec_id in self.active_graph_runs: + # TODO: Make this check cluster-wide, prevent duplicate runs across executor pods. + logger.error( + f"[{self.service_name}] Graph {graph_exec_id} already running; rejecting duplicate run." ) - if not credentials: - raise ValueError( - f"Unknown credentials #{credentials_meta.id} " - f"for node #{node.id}" + _ack_message(reject=True, requeue=False) + return + + cancel_event = threading.Event() + + future = self.executor.submit(execute_graph, graph_exec_entry, cancel_event) + self.active_graph_runs[graph_exec_id] = (future, cancel_event) + self._update_prompt_metrics() + + def _on_run_done(f: Future): + logger.info(f"[{self.service_name}] Run completed for {graph_exec_id}") + try: + if exec_error := f.exception(): + logger.error( + f"[{self.service_name}] Execution for {graph_exec_id} failed: {type(exec_error)} {exec_error}" + ) + _ack_message(reject=True, requeue=True) + else: + _ack_message(reject=False, requeue=False) + except BaseException as e: + logger.exception( + f"[{self.service_name}] Error in run completion callback: {e}" ) - if ( - credentials.provider != credentials_meta.provider - or credentials.type != credentials_meta.type - ): - logger.warning( - f"Invalid credentials #{credentials.id} for node #{node.id}: " - "type/provider mismatch: " - f"{credentials_meta.type}<>{credentials.type};" - f"{credentials_meta.provider}<>{credentials.provider}" + finally: + self._cleanup_completed_runs() + + future.add_done_callback(_on_run_done) + + def _cleanup_completed_runs(self) -> list[str]: + """Remove completed futures from active_graph_runs and update metrics""" + completed_runs = [] + for graph_exec_id, (future, _) in self.active_graph_runs.items(): + if future.done(): + completed_runs.append(graph_exec_id) + + for geid in completed_runs: + logger.info(f"[{self.service_name}] ✅ Cleaned up completed run {geid}") + self.active_graph_runs.pop(geid, None) + + self._update_prompt_metrics() + return completed_runs + + def _update_prompt_metrics(self): + active_count = len(self.active_graph_runs) + active_runs_gauge.set(active_count) + if self.stop_consuming.is_set(): + utilization_gauge.set(1.0) + else: + utilization_gauge.set(active_count / self.pool_size) + + def _stop_message_consumers( + self, thread: threading.Thread, client: SyncRabbitMQ, prefix: str + ): + try: + channel = client.get_channel() + channel.connection.add_callback_threadsafe(lambda: channel.stop_consuming()) + + try: + thread.join(timeout=300) + except TimeoutError: + logger.error( + f"{prefix} ⚠️ Run thread did not finish in time, forcing disconnect" ) - raise ValueError( - f"Invalid credentials #{credentials.id} for node #{node.id}: " - "type/provider mismatch" + + client.disconnect() + logger.info(f"{prefix} ✅ Run client disconnected") + except Exception as e: + logger.error(f"{prefix} ⚠️ Error disconnecting run client: {type(e)} {e}") + + def cleanup(self): + """Override cleanup to implement graceful shutdown with active execution waiting.""" + prefix = f"[{self.service_name}][on_graph_executor_stop {os.getpid()}]" + logger.info(f"{prefix} 🧹 Starting graceful shutdown...") + + # Signal the consumer thread to stop (thread-safe) + try: + self.stop_consuming.set() + run_channel = self.run_client.get_channel() + run_channel.connection.add_callback_threadsafe( + lambda: run_channel.stop_consuming() + ) + logger.info(f"{prefix} ✅ Exec consumer has been signaled to stop") + except Exception as e: + logger.error(f"{prefix} ⚠️ Error signaling consumer to stop: {type(e)} {e}") + + # Wait for active executions to complete + if self.active_graph_runs: + logger.info( + f"{prefix} ⏳ Waiting for {len(self.active_graph_runs)} active executions to complete..." + ) + + max_wait = GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS + wait_interval = 5 + waited = 0 + + while waited < max_wait: + self._cleanup_completed_runs() + if not self.active_graph_runs: + logger.info(f"{prefix} ✅ All active executions completed") + break + else: + ids = [k.split("-")[0] for k in self.active_graph_runs.keys()] + logger.info( + f"{prefix} ⏳ Still waiting for {len(self.active_graph_runs)} executions: {ids}" + ) + + time.sleep(wait_interval) + waited += wait_interval + + if self.active_graph_runs: + logger.error( + f"{prefix} ⚠️ {len(self.active_graph_runs)} executions still running after {max_wait}s" ) + else: + logger.info(f"{prefix} ✅ All executions completed gracefully") + + # Shutdown the executor + try: + self.executor.shutdown(cancel_futures=True, wait=False) + logger.info(f"{prefix} ✅ Executor shutdown completed") + except Exception as e: + logger.error(f"{prefix} ⚠️ Error during executor shutdown: {type(e)} {e}") + + # Disconnect the run execution consumer + self._stop_message_consumers( + self.run_thread, + self.run_client, + prefix + " [run-consumer]", + ) + self._stop_message_consumers( + self.cancel_thread, + self.cancel_client, + prefix + " [cancel-consumer]", + ) + + logger.info(f"{prefix} ✅ Finished GraphExec cleanup") # ------- UTILITIES ------- # -@thread_cached -def get_db_client() -> "DatabaseManager": - from backend.executor import DatabaseManager +def get_db_client() -> "DatabaseManagerClient": + return get_database_manager_client() + + +def get_db_async_client() -> "DatabaseManagerAsyncClient": + return get_database_manager_async_client() + + +@func_retry +async def send_async_execution_update( + entry: GraphExecution | NodeExecutionResult | None, +) -> None: + if entry is None: + return + await get_async_execution_event_bus().publish(entry) + + +@func_retry +def send_execution_update(entry: GraphExecution | NodeExecutionResult | None): + if entry is None: + return + return get_execution_event_bus().publish(entry) + + +async def async_update_node_execution_status( + db_client: "DatabaseManagerAsyncClient", + exec_id: str, + status: ExecutionStatus, + execution_data: BlockInput | None = None, + stats: dict[str, Any] | None = None, +) -> NodeExecutionResult: + """Sets status and fetches+broadcasts the latest state of the node execution""" + exec_update = await db_client.update_node_execution_status( + exec_id, status, execution_data, stats + ) + await send_async_execution_update(exec_update) + return exec_update + + +def update_node_execution_status( + db_client: "DatabaseManagerClient", + exec_id: str, + status: ExecutionStatus, + execution_data: BlockInput | None = None, + stats: dict[str, Any] | None = None, +) -> NodeExecutionResult: + """Sets status and fetches+broadcasts the latest state of the node execution""" + exec_update = db_client.update_node_execution_status( + exec_id, status, execution_data, stats + ) + send_execution_update(exec_update) + return exec_update + + +async def async_update_graph_execution_state( + db_client: "DatabaseManagerAsyncClient", + graph_exec_id: str, + status: ExecutionStatus | None = None, + stats: GraphExecutionStats | None = None, +) -> GraphExecution | None: + """Sets status and fetches+broadcasts the latest state of the graph execution""" + graph_update = await db_client.update_graph_execution_stats( + graph_exec_id, status, stats + ) + if graph_update: + await send_async_execution_update(graph_update) + else: + logger.error(f"Failed to update graph execution stats for {graph_exec_id}") + return graph_update - return get_service_client(DatabaseManager) + +def update_graph_execution_state( + db_client: "DatabaseManagerClient", + graph_exec_id: str, + status: ExecutionStatus | None = None, + stats: GraphExecutionStats | None = None, +) -> GraphExecution | None: + """Sets status and fetches+broadcasts the latest state of the graph execution""" + graph_update = db_client.update_graph_execution_stats(graph_exec_id, status, stats) + if graph_update: + send_execution_update(graph_update) + else: + logger.error(f"Failed to update graph execution stats for {graph_exec_id}") + return graph_update -@contextmanager -def synchronized(key: str, timeout: int = 60): - lock: RedisLock = redis.get_redis().lock(f"lock:{key}", timeout=timeout) +@asynccontextmanager +async def synchronized(key: str, timeout: int = 60): + r = await redis.get_redis_async() + lock: RedisLock = r.lock(f"lock:{key}", timeout=timeout) try: - lock.acquire() + await lock.acquire() yield finally: - lock.release() + if await lock.locked() and await lock.owned(): + await lock.release() -def llprint(message: str): +def increment_execution_count(user_id: str) -> int: """ - Low-level print/log helper function for use in signal handlers. - Regular log/print statements are not allowed in signal handlers. + Increment the execution count for a given user, + this will be used to charge the user for the execution cost. """ - if logger.getEffectiveLevel() == logging.DEBUG: - os.write(sys.stdout.fileno(), (message + "\n").encode()) + r = redis.get_redis() + k = f"uec:{user_id}" # User Execution Count global key + counter = cast(int, r.incr(k)) + if counter == 1: + r.expire(k, settings.config.execution_counter_expiration_time) + return counter diff --git a/autogpt_platform/backend/backend/executor/manager_low_balance_test.py b/autogpt_platform/backend/backend/executor/manager_low_balance_test.py new file mode 100644 index 000000000000..d51ffb251185 --- /dev/null +++ b/autogpt_platform/backend/backend/executor/manager_low_balance_test.py @@ -0,0 +1,149 @@ +from unittest.mock import MagicMock, patch + +import pytest +from prisma.enums import NotificationType + +from backend.data.notifications import LowBalanceData +from backend.executor.manager import ExecutionProcessor +from backend.util.test import SpinTestServer + + +@pytest.mark.asyncio(loop_scope="session") +async def test_handle_low_balance_threshold_crossing(server: SpinTestServer): + """Test that _handle_low_balance triggers notification when crossing threshold.""" + + execution_processor = ExecutionProcessor() + user_id = "test-user-123" + current_balance = 400 # $4 - below $5 threshold + transaction_cost = 600 # $6 transaction + + # Mock dependencies + with patch( + "backend.executor.manager.queue_notification" + ) as mock_queue_notif, patch( + "backend.executor.manager.get_notification_manager_client" + ) as mock_get_client, patch( + "backend.executor.manager.settings" + ) as mock_settings: + + # Setup mocks + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_settings.config.low_balance_threshold = 500 # $5 threshold + mock_settings.config.frontend_base_url = "https://test.com" + + # Create mock database client + mock_db_client = MagicMock() + mock_db_client.get_user_email_by_id.return_value = "test@example.com" + + # Test the low balance handler + execution_processor._handle_low_balance( + db_client=mock_db_client, + user_id=user_id, + current_balance=current_balance, + transaction_cost=transaction_cost, + ) + + # Verify notification was queued + mock_queue_notif.assert_called_once() + notification_call = mock_queue_notif.call_args[0][0] + + # Verify notification details + assert notification_call.type == NotificationType.LOW_BALANCE + assert notification_call.user_id == user_id + assert isinstance(notification_call.data, LowBalanceData) + assert notification_call.data.current_balance == current_balance + + # Verify Discord alert was sent + mock_client.discord_system_alert.assert_called_once() + discord_message = mock_client.discord_system_alert.call_args[0][0] + assert "Low Balance Alert" in discord_message + assert "test@example.com" in discord_message + assert "$4.00" in discord_message + assert "$6.00" in discord_message + + +@pytest.mark.asyncio(loop_scope="session") +async def test_handle_low_balance_no_notification_when_not_crossing( + server: SpinTestServer, +): + """Test that no notification is sent when not crossing the threshold.""" + + execution_processor = ExecutionProcessor() + user_id = "test-user-123" + current_balance = 600 # $6 - above $5 threshold + transaction_cost = ( + 100 # $1 transaction (balance before was $7, still above threshold) + ) + + # Mock dependencies + with patch( + "backend.executor.manager.queue_notification" + ) as mock_queue_notif, patch( + "backend.executor.manager.get_notification_manager_client" + ) as mock_get_client, patch( + "backend.executor.manager.settings" + ) as mock_settings: + + # Setup mocks + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_settings.config.low_balance_threshold = 500 # $5 threshold + + # Create mock database client + mock_db_client = MagicMock() + + # Test the low balance handler + execution_processor._handle_low_balance( + db_client=mock_db_client, + user_id=user_id, + current_balance=current_balance, + transaction_cost=transaction_cost, + ) + + # Verify no notification was sent + mock_queue_notif.assert_not_called() + mock_client.discord_system_alert.assert_not_called() + + +@pytest.mark.asyncio(loop_scope="session") +async def test_handle_low_balance_no_duplicate_when_already_below( + server: SpinTestServer, +): + """Test that no notification is sent when already below threshold.""" + + execution_processor = ExecutionProcessor() + user_id = "test-user-123" + current_balance = 300 # $3 - below $5 threshold + transaction_cost = ( + 100 # $1 transaction (balance before was $4, also below threshold) + ) + + # Mock dependencies + with patch( + "backend.executor.manager.queue_notification" + ) as mock_queue_notif, patch( + "backend.executor.manager.get_notification_manager_client" + ) as mock_get_client, patch( + "backend.executor.manager.settings" + ) as mock_settings: + + # Setup mocks + mock_client = MagicMock() + mock_get_client.return_value = mock_client + mock_settings.config.low_balance_threshold = 500 # $5 threshold + + # Create mock database client + mock_db_client = MagicMock() + + # Test the low balance handler + execution_processor._handle_low_balance( + db_client=mock_db_client, + user_id=user_id, + current_balance=current_balance, + transaction_cost=transaction_cost, + ) + + # Verify no notification was sent (user was already below threshold) + mock_queue_notif.assert_not_called() + mock_client.discord_system_alert.assert_not_called() diff --git a/autogpt_platform/backend/backend/executor/manager_test.py b/autogpt_platform/backend/backend/executor/manager_test.py new file mode 100644 index 000000000000..c565eedfbfe3 --- /dev/null +++ b/autogpt_platform/backend/backend/executor/manager_test.py @@ -0,0 +1,543 @@ +import logging + +import autogpt_libs.auth.models +import fastapi.responses +import pytest + +import backend.server.v2.library.model +import backend.server.v2.store.model +from backend.blocks.basic import StoreValueBlock +from backend.blocks.data_manipulation import FindInDictionaryBlock +from backend.blocks.io import AgentInputBlock +from backend.blocks.maths import CalculatorBlock, Operation +from backend.data import execution, graph +from backend.data.model import User +from backend.server.model import CreateGraph +from backend.server.rest_api import AgentServer +from backend.usecases.sample import create_test_graph, create_test_user +from backend.util.test import SpinTestServer, wait_execution + +logger = logging.getLogger(__name__) + + +async def create_graph(s: SpinTestServer, g: graph.Graph, u: User) -> graph.Graph: + logger.info(f"Creating graph for user {u.id}") + return await s.agent_server.test_create_graph(CreateGraph(graph=g), u.id) + + +async def execute_graph( + agent_server: AgentServer, + test_graph: graph.Graph, + test_user: User, + input_data: dict, + num_execs: int = 4, +) -> str: + logger.info(f"Executing graph {test_graph.id} for user {test_user.id}") + logger.info(f"Input data: {input_data}") + + # --- Test adding new executions --- # + response = await agent_server.test_execute_graph( + user_id=test_user.id, + graph_id=test_graph.id, + graph_version=test_graph.version, + node_input=input_data, + ) + graph_exec_id = response.graph_exec_id + logger.info(f"Created execution with ID: {graph_exec_id}") + + # Execution queue should be empty + logger.info("Waiting for execution to complete...") + result = await wait_execution(test_user.id, graph_exec_id, 30) + logger.info(f"Execution completed with {len(result)} results") + assert len(result) == num_execs + return graph_exec_id + + +async def assert_sample_graph_executions( + test_graph: graph.Graph, + test_user: User, + graph_exec_id: str, +): + logger.info(f"Checking execution results for graph {test_graph.id}") + graph_run = await execution.get_graph_execution( + test_user.id, graph_exec_id, include_node_executions=True + ) + assert isinstance(graph_run, execution.GraphExecutionWithNodes) + + output_list = [{"result": ["Hello"]}, {"result": ["World"]}] + input_list = [ + { + "name": "input_1", + "value": "Hello", + }, + { + "name": "input_2", + "value": "World", + "description": "This is my description of this parameter", + }, + ] + + # Executing StoreValueBlock + exec = graph_run.node_executions[0] + logger.info(f"Checking first StoreValueBlock execution: {exec}") + assert exec.status == execution.ExecutionStatus.COMPLETED + assert exec.graph_exec_id == graph_exec_id + assert ( + exec.output_data in output_list + ), f"Output data: {exec.output_data} and {output_list}" + assert ( + exec.input_data in input_list + ), f"Input data: {exec.input_data} and {input_list}" + assert exec.node_id in [test_graph.nodes[0].id, test_graph.nodes[1].id] + + # Executing StoreValueBlock + exec = graph_run.node_executions[1] + logger.info(f"Checking second StoreValueBlock execution: {exec}") + assert exec.status == execution.ExecutionStatus.COMPLETED + assert exec.graph_exec_id == graph_exec_id + assert ( + exec.output_data in output_list + ), f"Output data: {exec.output_data} and {output_list}" + assert ( + exec.input_data in input_list + ), f"Input data: {exec.input_data} and {input_list}" + assert exec.node_id in [test_graph.nodes[0].id, test_graph.nodes[1].id] + + # Executing FillTextTemplateBlock + exec = graph_run.node_executions[2] + logger.info(f"Checking FillTextTemplateBlock execution: {exec}") + assert exec.status == execution.ExecutionStatus.COMPLETED + assert exec.graph_exec_id == graph_exec_id + assert exec.output_data == {"output": ["Hello, World!!!"]} + assert exec.input_data == { + "format": "{{a}}, {{b}}{{c}}", + "values": {"a": "Hello", "b": "World", "c": "!!!"}, + "values_#_a": "Hello", + "values_#_b": "World", + "values_#_c": "!!!", + } + assert exec.node_id == test_graph.nodes[2].id + + # Executing PrintToConsoleBlock + exec = graph_run.node_executions[3] + logger.info(f"Checking PrintToConsoleBlock execution: {exec}") + assert exec.status == execution.ExecutionStatus.COMPLETED + assert exec.graph_exec_id == graph_exec_id + assert exec.output_data == {"output": ["Hello, World!!!"]} + assert exec.input_data == {"input": "Hello, World!!!"} + assert exec.node_id == test_graph.nodes[3].id + + +@pytest.mark.asyncio(loop_scope="session") +async def test_agent_execution(server: SpinTestServer): + logger.info("Starting test_agent_execution") + test_user = await create_test_user() + test_graph = await create_graph(server, create_test_graph(), test_user) + data = {"input_1": "Hello", "input_2": "World"} + graph_exec_id = await execute_graph( + server.agent_server, + test_graph, + test_user, + data, + 4, + ) + await assert_sample_graph_executions(test_graph, test_user, graph_exec_id) + logger.info("Completed test_agent_execution") + + +@pytest.mark.asyncio(loop_scope="session") +async def test_input_pin_always_waited(server: SpinTestServer): + """ + This test is asserting that the input pin should always be waited for the execution, + even when default value on that pin is defined, the value has to be ignored. + + Test scenario: + StoreValueBlock1 + \\ input + >------- FindInDictionaryBlock | input_default: key: "", input: {} + // key + StoreValueBlock2 + """ + logger.info("Starting test_input_pin_always_waited") + nodes = [ + graph.Node( + block_id=StoreValueBlock().id, + input_default={"input": {"key1": "value1", "key2": "value2"}}, + ), + graph.Node( + block_id=StoreValueBlock().id, + input_default={"input": "key2"}, + ), + graph.Node( + block_id=FindInDictionaryBlock().id, + input_default={"key": "", "input": {}}, + ), + ] + links = [ + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[2].id, + source_name="output", + sink_name="input", + ), + graph.Link( + source_id=nodes[1].id, + sink_id=nodes[2].id, + source_name="output", + sink_name="key", + ), + ] + test_graph = graph.Graph( + name="TestGraph", + description="Test graph", + nodes=nodes, + links=links, + ) + test_user = await create_test_user() + test_graph = await create_graph(server, test_graph, test_user) + graph_exec_id = await execute_graph( + server.agent_server, test_graph, test_user, {}, 3 + ) + + logger.info("Checking execution results") + graph_exec = await execution.get_graph_execution( + test_user.id, graph_exec_id, include_node_executions=True + ) + assert isinstance(graph_exec, execution.GraphExecutionWithNodes) + assert len(graph_exec.node_executions) == 3 + # FindInDictionaryBlock should wait for the input pin to be provided, + # Hence executing extraction of "key" from {"key1": "value1", "key2": "value2"} + assert graph_exec.node_executions[2].status == execution.ExecutionStatus.COMPLETED + assert graph_exec.node_executions[2].output_data == {"output": ["value2"]} + logger.info("Completed test_input_pin_always_waited") + + +@pytest.mark.asyncio(loop_scope="session") +async def test_static_input_link_on_graph(server: SpinTestServer): + """ + This test is asserting the behaviour of static input link, e.g: reusable input link. + + Test scenario: + *StoreValueBlock1*===a=========\\ + *StoreValueBlock2*===a=====\\ || + *StoreValueBlock3*===a===*MathBlock*====b / static====*StoreValueBlock5* + *StoreValueBlock4*=========================================// + + In this test, there will be three input waiting in the MathBlock input pin `a`. + And later, another output is produced on input pin `b`, which is a static link, + this input will complete the input of those three incomplete executions. + """ + logger.info("Starting test_static_input_link_on_graph") + nodes = [ + graph.Node(block_id=StoreValueBlock().id, input_default={"input": 4}), # a + graph.Node(block_id=StoreValueBlock().id, input_default={"input": 4}), # a + graph.Node(block_id=StoreValueBlock().id, input_default={"input": 4}), # a + graph.Node(block_id=StoreValueBlock().id, input_default={"input": 5}), # b + graph.Node(block_id=StoreValueBlock().id), + graph.Node( + block_id=CalculatorBlock().id, + input_default={"operation": Operation.ADD.value}, + ), + ] + links = [ + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[5].id, + source_name="output", + sink_name="a", + ), + graph.Link( + source_id=nodes[1].id, + sink_id=nodes[5].id, + source_name="output", + sink_name="a", + ), + graph.Link( + source_id=nodes[2].id, + sink_id=nodes[5].id, + source_name="output", + sink_name="a", + ), + graph.Link( + source_id=nodes[3].id, + sink_id=nodes[4].id, + source_name="output", + sink_name="input", + ), + graph.Link( + source_id=nodes[4].id, + sink_id=nodes[5].id, + source_name="output", + sink_name="b", + is_static=True, # This is the static link to test. + ), + ] + test_graph = graph.Graph( + name="TestGraph", + description="Test graph", + nodes=nodes, + links=links, + ) + test_user = await create_test_user() + test_graph = await create_graph(server, test_graph, test_user) + graph_exec_id = await execute_graph( + server.agent_server, test_graph, test_user, {}, 8 + ) + logger.info("Checking execution results") + graph_exec = await execution.get_graph_execution( + test_user.id, graph_exec_id, include_node_executions=True + ) + assert isinstance(graph_exec, execution.GraphExecutionWithNodes) + assert len(graph_exec.node_executions) == 8 + # The last 3 executions will be a+b=4+5=9 + for i, exec_data in enumerate(graph_exec.node_executions[-3:]): + logger.info(f"Checking execution {i+1} of last 3: {exec_data}") + assert exec_data.status == execution.ExecutionStatus.COMPLETED + assert exec_data.output_data == {"result": [9]} + logger.info("Completed test_static_input_link_on_graph") + + +@pytest.mark.asyncio(loop_scope="session") +async def test_execute_preset(server: SpinTestServer): + """ + Test executing a preset. + + This test ensures that: + 1. A preset can be successfully executed + 2. The execution results are correct + + Args: + server (SpinTestServer): The test server instance. + """ + # Create test graph and user + nodes = [ + graph.Node( # 0 + block_id=AgentInputBlock().id, + input_default={"name": "dictionary"}, + ), + graph.Node( # 1 + block_id=AgentInputBlock().id, + input_default={"name": "selected_value"}, + ), + graph.Node( # 2 + block_id=StoreValueBlock().id, + input_default={"input": {"key1": "Hi", "key2": "Everyone"}}, + ), + graph.Node( # 3 + block_id=FindInDictionaryBlock().id, + input_default={"key": "", "input": {}}, + ), + ] + links = [ + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[2].id, + source_name="result", + sink_name="input", + ), + graph.Link( + source_id=nodes[1].id, + sink_id=nodes[3].id, + source_name="result", + sink_name="key", + ), + graph.Link( + source_id=nodes[2].id, + sink_id=nodes[3].id, + source_name="output", + sink_name="input", + ), + ] + test_graph = graph.Graph( + name="TestGraph", + description="Test graph", + nodes=nodes, + links=links, + ) + test_user = await create_test_user() + test_graph = await create_graph(server, test_graph, test_user) + + # Create preset with initial values + preset = backend.server.v2.library.model.LibraryAgentPresetCreatable( + name="Test Preset With Clash", + description="Test preset with clashing input values", + graph_id=test_graph.id, + graph_version=test_graph.version, + inputs={ + "dictionary": {"key1": "Hello", "key2": "World"}, + "selected_value": "key2", + }, + credentials={}, + is_active=True, + ) + created_preset = await server.agent_server.test_create_preset(preset, test_user.id) + + # Execute preset with overriding values + result = await server.agent_server.test_execute_preset( + preset_id=created_preset.id, + user_id=test_user.id, + ) + + # Verify execution + assert result is not None + graph_exec_id = result["id"] + + # Wait for execution to complete + executions = await wait_execution(test_user.id, graph_exec_id) + assert len(executions) == 4 + + # FindInDictionaryBlock should wait for the input pin to be provided, + # Hence executing extraction of "key" from {"key1": "value1", "key2": "value2"} + assert executions[3].status == execution.ExecutionStatus.COMPLETED + assert executions[3].output_data == {"output": ["World"]} + + +@pytest.mark.asyncio(loop_scope="session") +async def test_execute_preset_with_clash(server: SpinTestServer): + """ + Test executing a preset with clashing input data. + """ + # Create test graph and user + nodes = [ + graph.Node( # 0 + block_id=AgentInputBlock().id, + input_default={"name": "dictionary"}, + ), + graph.Node( # 1 + block_id=AgentInputBlock().id, + input_default={"name": "selected_value"}, + ), + graph.Node( # 2 + block_id=StoreValueBlock().id, + input_default={"input": {"key1": "Hi", "key2": "Everyone"}}, + ), + graph.Node( # 3 + block_id=FindInDictionaryBlock().id, + input_default={"key": "", "input": {}}, + ), + ] + links = [ + graph.Link( + source_id=nodes[0].id, + sink_id=nodes[2].id, + source_name="result", + sink_name="input", + ), + graph.Link( + source_id=nodes[1].id, + sink_id=nodes[3].id, + source_name="result", + sink_name="key", + ), + graph.Link( + source_id=nodes[2].id, + sink_id=nodes[3].id, + source_name="output", + sink_name="input", + ), + ] + test_graph = graph.Graph( + name="TestGraph", + description="Test graph", + nodes=nodes, + links=links, + ) + test_user = await create_test_user() + test_graph = await create_graph(server, test_graph, test_user) + + # Create preset with initial values + preset = backend.server.v2.library.model.LibraryAgentPresetCreatable( + name="Test Preset With Clash", + description="Test preset with clashing input values", + graph_id=test_graph.id, + graph_version=test_graph.version, + inputs={ + "dictionary": {"key1": "Hello", "key2": "World"}, + "selected_value": "key2", + }, + credentials={}, + is_active=True, + ) + created_preset = await server.agent_server.test_create_preset(preset, test_user.id) + + # Execute preset with overriding values + result = await server.agent_server.test_execute_preset( + preset_id=created_preset.id, + inputs={"selected_value": "key1"}, + user_id=test_user.id, + ) + + # Verify execution + assert result is not None, "Result must not be None" + graph_exec_id = result["id"] + + # Wait for execution to complete + executions = await wait_execution(test_user.id, graph_exec_id) + assert len(executions) == 4 + + # FindInDictionaryBlock should wait for the input pin to be provided, + # Hence executing extraction of "key" from {"key1": "value1", "key2": "value2"} + assert executions[3].status == execution.ExecutionStatus.COMPLETED + assert executions[3].output_data == {"output": ["Hello"]} + + +@pytest.mark.asyncio(loop_scope="session") +async def test_store_listing_graph(server: SpinTestServer): + logger.info("Starting test_agent_execution") + test_user = await create_test_user() + test_graph = await create_graph(server, create_test_graph(), test_user) + + store_submission_request = backend.server.v2.store.model.StoreSubmissionRequest( + agent_id=test_graph.id, + agent_version=test_graph.version, + slug=test_graph.id, + name="Test name", + sub_heading="Test sub heading", + video_url=None, + image_urls=[], + description="Test description", + categories=[], + ) + + store_listing = await server.agent_server.test_create_store_listing( + store_submission_request, test_user.id + ) + + if isinstance(store_listing, fastapi.responses.JSONResponse): + assert False, "Failed to create store listing" + + slv_id = ( + store_listing.store_listing_version_id + if store_listing.store_listing_version_id is not None + else None + ) + + assert slv_id is not None + + admin_user = await create_test_user(alt_user=True) + await server.agent_server.test_review_store_listing( + backend.server.v2.store.model.ReviewSubmissionRequest( + store_listing_version_id=slv_id, + is_approved=True, + comments="Test comments", + ), + autogpt_libs.auth.models.User( + user_id=admin_user.id, + role="admin", + email=admin_user.email, + phone_number="1234567890", + ), + ) + alt_test_user = admin_user + + data = {"input_1": "Hello", "input_2": "World"} + graph_exec_id = await execute_graph( + server.agent_server, + test_graph, + alt_test_user, + data, + 4, + ) + + await assert_sample_graph_executions(test_graph, alt_test_user, graph_exec_id) + logger.info("Completed test_agent_execution") diff --git a/autogpt_platform/backend/backend/executor/scheduler.py b/autogpt_platform/backend/backend/executor/scheduler.py index 3c906a3afc2a..e476b3ceed99 100644 --- a/autogpt_platform/backend/backend/executor/scheduler.py +++ b/autogpt_platform/backend/backend/executor/scheduler.py @@ -1,20 +1,49 @@ +import asyncio import logging import os +import threading +from enum import Enum +from typing import Optional from urllib.parse import parse_qs, urlencode, urlparse, urlunparse -from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED +from apscheduler.events import ( + EVENT_JOB_ERROR, + EVENT_JOB_EXECUTED, + EVENT_JOB_MAX_INSTANCES, + EVENT_JOB_MISSED, +) from apscheduler.job import Job as JobObj +from apscheduler.jobstores.memory import MemoryJobStore from apscheduler.jobstores.sqlalchemy import SQLAlchemyJobStore -from apscheduler.schedulers.blocking import BlockingScheduler +from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger -from autogpt_libs.utils.cache import thread_cached +from apscheduler.util import ZoneInfo from dotenv import load_dotenv -from pydantic import BaseModel +from pydantic import BaseModel, Field, ValidationError from sqlalchemy import MetaData, create_engine from backend.data.block import BlockInput -from backend.executor.manager import ExecutionManager -from backend.util.service import AppService, expose, get_service_client +from backend.data.execution import GraphExecutionWithNodes +from backend.data.model import CredentialsMetaInput +from backend.executor import utils as execution_utils +from backend.monitoring import ( + NotificationJobArgs, + process_existing_batches, + process_weekly_summary, + report_block_error_rates, + report_late_executions, +) +from backend.util.cloud_storage import cleanup_expired_files_async +from backend.util.exceptions import NotAuthorizedError, NotFoundError +from backend.util.logging import PrefixFilter +from backend.util.retry import func_retry +from backend.util.service import ( + AppService, + AppServiceClient, + UnhealthyServiceError, + endpoint_to_async, + expose, +) from backend.util.settings import Config @@ -38,53 +67,153 @@ def _extract_schema_from_url(database_url) -> tuple[str, str]: logger = logging.getLogger(__name__) -config = Config() +logger.addFilter(PrefixFilter("[Scheduler]")) +apscheduler_logger = logger.getChild("apscheduler") +apscheduler_logger.addFilter(PrefixFilter("[Scheduler] [APScheduler]")) +config = Config() -def log(msg, **kwargs): - logger.info("[ExecutionScheduler] " + msg, **kwargs) +# Timeout constants +SCHEDULER_OPERATION_TIMEOUT_SECONDS = 300 # 5 minutes for scheduler operations def job_listener(event): """Logs job execution outcomes for better monitoring.""" if event.exception: - log(f"Job {event.job_id} failed.") + logger.error( + f"Job {event.job_id} failed: {type(event.exception).__name__}: {event.exception}" + ) else: - log(f"Job {event.job_id} completed successfully.") + logger.info(f"Job {event.job_id} completed successfully.") + + +def job_missed_listener(event): + """Logs when jobs are missed due to scheduling issues.""" + logger.warning( + f"Job {event.job_id} was missed at scheduled time {event.scheduled_run_time}. " + f"This can happen if the scheduler is overloaded or if previous executions are still running." + ) + + +def job_max_instances_listener(event): + """Logs when jobs hit max instances limit.""" + logger.warning( + f"Job {event.job_id} execution was SKIPPED - max instances limit reached. " + f"Previous execution(s) are still running. " + f"Consider increasing max_instances or check why previous executions are taking too long." + ) + + +_event_loop: asyncio.AbstractEventLoop | None = None +_event_loop_thread: threading.Thread | None = None + +@func_retry +def get_event_loop(): + """Get the shared event loop.""" + if _event_loop is None: + raise RuntimeError("Event loop not initialized. Scheduler not started.") + return _event_loop -@thread_cached -def get_execution_client() -> ExecutionManager: - return get_service_client(ExecutionManager) + +def run_async(coro, timeout: float = SCHEDULER_OPERATION_TIMEOUT_SECONDS): + """Run a coroutine in the shared event loop and wait for completion.""" + loop = get_event_loop() + future = asyncio.run_coroutine_threadsafe(coro, loop) + try: + return future.result(timeout=timeout) + except Exception as e: + logger.error(f"Async operation failed: {type(e).__name__}: {e}") + raise def execute_graph(**kwargs): - args = JobArgs(**kwargs) + """Execute graph in the shared event loop and wait for completion.""" + # Wait for completion to ensure job doesn't exit prematurely + run_async(_execute_graph(**kwargs)) + + +async def _execute_graph(**kwargs): + args = GraphExecutionJobArgs(**kwargs) + start_time = asyncio.get_event_loop().time() try: - log(f"Executing recurring job for graph #{args.graph_id}") - get_execution_client().add_execution( - args.graph_id, args.input_data, args.user_id + logger.info(f"Executing recurring job for graph #{args.graph_id}") + graph_exec: GraphExecutionWithNodes = await execution_utils.add_graph_execution( + user_id=args.user_id, + graph_id=args.graph_id, + graph_version=args.graph_version, + inputs=args.input_data, + graph_credentials_inputs=args.input_credentials, ) + elapsed = asyncio.get_event_loop().time() - start_time + logger.info( + f"Graph execution started with ID {graph_exec.id} for graph {args.graph_id} " + f"(took {elapsed:.2f}s to create and publish)" + ) + if elapsed > 10: + logger.warning( + f"Graph execution {graph_exec.id} took {elapsed:.2f}s to create/publish - " + f"this is unusually slow and may indicate resource contention" + ) except Exception as e: - logger.exception(f"Error executing graph {args.graph_id}: {e}") + elapsed = asyncio.get_event_loop().time() - start_time + logger.error( + f"Error executing graph {args.graph_id} after {elapsed:.2f}s: " + f"{type(e).__name__}: {e}" + ) -class JobArgs(BaseModel): - graph_id: str - input_data: BlockInput +def cleanup_expired_files(): + """Clean up expired files from cloud storage.""" + # Wait for completion + run_async(cleanup_expired_files_async()) + + +# Monitoring functions are now imported from monitoring module + + +class Jobstores(Enum): + EXECUTION = "execution" + BATCHED_NOTIFICATIONS = "batched_notifications" + WEEKLY_NOTIFICATIONS = "weekly_notifications" + + +class GraphExecutionJobArgs(BaseModel): user_id: str + graph_id: str graph_version: int cron: str + input_data: BlockInput + input_credentials: dict[str, CredentialsMetaInput] = Field(default_factory=dict) + + +class GraphExecutionJobInfo(GraphExecutionJobArgs): + id: str + name: str + next_run_time: str + + @staticmethod + def from_db( + job_args: GraphExecutionJobArgs, job_obj: JobObj + ) -> "GraphExecutionJobInfo": + return GraphExecutionJobInfo( + id=job_obj.id, + name=job_obj.name, + next_run_time=job_obj.next_run_time.isoformat(), + **job_args.model_dump(), + ) -class JobInfo(JobArgs): +class NotificationJobInfo(NotificationJobArgs): id: str name: str next_run_time: str @staticmethod - def from_db(job_args: JobArgs, job_obj: JobObj) -> "JobInfo": - return JobInfo( + def from_db( + job_args: NotificationJobArgs, job_obj: JobObj + ) -> "NotificationJobInfo": + return NotificationJobInfo( id=job_obj.id, name=job_obj.name, next_run_time=job_obj.next_run_time.isoformat(), @@ -92,84 +221,282 @@ def from_db(job_args: JobArgs, job_obj: JobObj) -> "JobInfo": ) -class ExecutionScheduler(AppService): - scheduler: BlockingScheduler +class Scheduler(AppService): + scheduler: BackgroundScheduler + + def __init__(self, register_system_tasks: bool = True): + self.register_system_tasks = register_system_tasks @classmethod def get_port(cls) -> int: return config.execution_scheduler_port - @property - @thread_cached - def execution_client(self) -> ExecutionManager: - return get_service_client(ExecutionManager) + @classmethod + def db_pool_size(cls) -> int: + return config.scheduler_db_pool_size + + async def health_check(self) -> str: + # Thread-safe health check with proper initialization handling + if not hasattr(self, "scheduler"): + raise UnhealthyServiceError("Scheduler is still initializing") + + # Check if we're in the middle of cleanup + if self.cleaned_up: + return await super().health_check() + + # Normal operation - check if scheduler is running + if not self.scheduler.running: + raise UnhealthyServiceError("Scheduler is not running") + + return await super().health_check() def run_service(self): load_dotenv() - db_schema, db_url = _extract_schema_from_url(os.getenv("DATABASE_URL")) - self.scheduler = BlockingScheduler( + + # Initialize the event loop for async jobs + global _event_loop + _event_loop = asyncio.new_event_loop() + + # Use daemon thread since it should die with the main service + global _event_loop_thread + _event_loop_thread = threading.Thread( + target=_event_loop.run_forever, daemon=True, name="SchedulerEventLoop" + ) + _event_loop_thread.start() + + db_schema, db_url = _extract_schema_from_url(os.getenv("DIRECT_URL")) + # Configure executors to limit concurrency without skipping jobs + from apscheduler.executors.pool import ThreadPoolExecutor + + self.scheduler = BackgroundScheduler( + executors={ + "default": ThreadPoolExecutor( + max_workers=self.db_pool_size() + ), # Match DB pool size to prevent resource contention + }, + job_defaults={ + "coalesce": True, # Skip redundant missed jobs - just run the latest + "max_instances": 1000, # Effectively unlimited - never drop executions + "misfire_grace_time": None, # No time limit for missed jobs + }, jobstores={ - "default": SQLAlchemyJobStore( - engine=create_engine(db_url), + Jobstores.EXECUTION.value: SQLAlchemyJobStore( + engine=create_engine( + url=db_url, + pool_size=self.db_pool_size(), + max_overflow=0, + ), metadata=MetaData(schema=db_schema), - ) - } + # this one is pre-existing so it keeps the + # default table name. + tablename="apscheduler_jobs", + ), + Jobstores.BATCHED_NOTIFICATIONS.value: SQLAlchemyJobStore( + engine=create_engine( + url=db_url, + pool_size=self.db_pool_size(), + max_overflow=0, + ), + metadata=MetaData(schema=db_schema), + tablename="apscheduler_jobs_batched_notifications", + ), + # These don't really need persistence + Jobstores.WEEKLY_NOTIFICATIONS.value: MemoryJobStore(), + }, + logger=apscheduler_logger, + timezone=ZoneInfo("UTC"), ) + + if self.register_system_tasks: + # Notification PROCESS WEEKLY SUMMARY + # Runs every Monday at 9 AM UTC + self.scheduler.add_job( + process_weekly_summary, + CronTrigger.from_crontab("0 9 * * 1"), + id="process_weekly_summary", + kwargs={}, + replace_existing=True, + jobstore=Jobstores.WEEKLY_NOTIFICATIONS.value, + ) + + # Notification PROCESS EXISTING BATCHES + # self.scheduler.add_job( + # process_existing_batches, + # id="process_existing_batches", + # CronTrigger.from_crontab("0 12 * * 5"), + # replace_existing=True, + # jobstore=Jobstores.BATCHED_NOTIFICATIONS.value, + # ) + + # Notification LATE EXECUTIONS ALERT + self.scheduler.add_job( + report_late_executions, + id="report_late_executions", + trigger="interval", + replace_existing=True, + seconds=config.execution_late_notification_threshold_secs, + jobstore=Jobstores.EXECUTION.value, + ) + + # Block Error Rate Monitoring + self.scheduler.add_job( + report_block_error_rates, + id="report_block_error_rates", + trigger="interval", + replace_existing=True, + seconds=config.block_error_rate_check_interval_secs, + jobstore=Jobstores.EXECUTION.value, + ) + + # Cloud Storage Cleanup - configurable interval + self.scheduler.add_job( + cleanup_expired_files, + id="cleanup_expired_files", + trigger="interval", + replace_existing=True, + seconds=config.cloud_storage_cleanup_interval_hours + * 3600, # Convert hours to seconds + jobstore=Jobstores.EXECUTION.value, + ) + self.scheduler.add_listener(job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR) + self.scheduler.add_listener(job_missed_listener, EVENT_JOB_MISSED) + self.scheduler.add_listener(job_max_instances_listener, EVENT_JOB_MAX_INSTANCES) self.scheduler.start() + # Keep the service running since BackgroundScheduler doesn't block + super().run_service() + + def cleanup(self): + super().cleanup() + if self.scheduler: + logger.info("⏳ Shutting down scheduler...") + self.scheduler.shutdown(wait=True) + + global _event_loop + if _event_loop: + logger.info("⏳ Closing event loop...") + _event_loop.call_soon_threadsafe(_event_loop.stop) + + global _event_loop_thread + if _event_loop_thread: + logger.info("⏳ Waiting for event loop thread to finish...") + _event_loop_thread.join(timeout=SCHEDULER_OPERATION_TIMEOUT_SECONDS) + + logger.info("Scheduler cleanup complete.") + @expose - def add_execution_schedule( + def add_graph_execution_schedule( self, + user_id: str, graph_id: str, graph_version: int, cron: str, input_data: BlockInput, - user_id: str, - ) -> JobInfo: - job_args = JobArgs( - graph_id=graph_id, - input_data=input_data, + input_credentials: dict[str, CredentialsMetaInput], + name: Optional[str] = None, + ) -> GraphExecutionJobInfo: + # Validate the graph before scheduling to prevent runtime failures + # We don't need the return value, just want the validation to run + run_async( + execution_utils.validate_and_construct_node_execution_input( + graph_id=graph_id, + user_id=user_id, + graph_inputs=input_data, + graph_version=graph_version, + graph_credentials_inputs=input_credentials, + ) + ) + + logger.info(f"Scheduling job for user {user_id} in UTC (cron: {cron})") + + job_args = GraphExecutionJobArgs( user_id=user_id, + graph_id=graph_id, graph_version=graph_version, cron=cron, + input_data=input_data, + input_credentials=input_credentials, ) job = self.scheduler.add_job( execute_graph, - CronTrigger.from_crontab(cron), kwargs=job_args.model_dump(), + name=name, + trigger=CronTrigger.from_crontab(cron, timezone="UTC"), + jobstore=Jobstores.EXECUTION.value, replace_existing=True, ) - log(f"Added job {job.id} with cron schedule '{cron}' input data: {input_data}") - return JobInfo.from_db(job_args, job) + logger.info( + f"Added job {job.id} with cron schedule '{cron}' in UTC, input data: {input_data}" + ) + return GraphExecutionJobInfo.from_db(job_args, job) @expose - def delete_schedule(self, schedule_id: str, user_id: str) -> JobInfo: - job = self.scheduler.get_job(schedule_id) + def delete_graph_execution_schedule( + self, schedule_id: str, user_id: str + ) -> GraphExecutionJobInfo: + job = self.scheduler.get_job(schedule_id, jobstore=Jobstores.EXECUTION.value) if not job: - log(f"Job {schedule_id} not found.") - raise ValueError(f"Job #{schedule_id} not found.") + raise NotFoundError(f"Job #{schedule_id} not found.") - job_args = JobArgs(**job.kwargs) + job_args = GraphExecutionJobArgs(**job.kwargs) if job_args.user_id != user_id: - raise ValueError("User ID does not match the job's user ID.") + raise NotAuthorizedError("User ID does not match the job's user ID") - log(f"Deleting job {schedule_id}") + logger.info(f"Deleting job {schedule_id}") job.remove() - return JobInfo.from_db(job_args, job) + return GraphExecutionJobInfo.from_db(job_args, job) @expose - def get_execution_schedules( + def get_graph_execution_schedules( self, graph_id: str | None = None, user_id: str | None = None - ) -> list[JobInfo]: + ) -> list[GraphExecutionJobInfo]: + jobs: list[JobObj] = self.scheduler.get_jobs(jobstore=Jobstores.EXECUTION.value) schedules = [] - for job in self.scheduler.get_jobs(): - job_args = JobArgs(**job.kwargs) + for job in jobs: + logger.debug( + f"Found job {job.id} with cron schedule {job.trigger} and args {job.kwargs}" + ) + try: + job_args = GraphExecutionJobArgs.model_validate(job.kwargs) + except ValidationError: + continue if ( job.next_run_time is not None and (graph_id is None or job_args.graph_id == graph_id) and (user_id is None or job_args.user_id == user_id) ): - schedules.append(JobInfo.from_db(job_args, job)) + schedules.append(GraphExecutionJobInfo.from_db(job_args, job)) return schedules + + @expose + def execute_process_existing_batches(self, kwargs: dict): + process_existing_batches(**kwargs) + + @expose + def execute_process_weekly_summary(self): + process_weekly_summary() + + @expose + def execute_report_late_executions(self): + return report_late_executions() + + @expose + def execute_report_block_error_rates(self): + return report_block_error_rates() + + @expose + def execute_cleanup_expired_files(self): + """Manually trigger cleanup of expired cloud storage files.""" + return cleanup_expired_files() + + +class SchedulerClient(AppServiceClient): + @classmethod + def get_service_type(cls): + return Scheduler + + add_execution_schedule = endpoint_to_async(Scheduler.add_graph_execution_schedule) + delete_schedule = endpoint_to_async(Scheduler.delete_graph_execution_schedule) + get_execution_schedules = endpoint_to_async(Scheduler.get_graph_execution_schedules) diff --git a/autogpt_platform/backend/backend/executor/scheduler_test.py b/autogpt_platform/backend/backend/executor/scheduler_test.py new file mode 100644 index 000000000000..c4fa35d46c60 --- /dev/null +++ b/autogpt_platform/backend/backend/executor/scheduler_test.py @@ -0,0 +1,41 @@ +import pytest + +from backend.data import db +from backend.server.model import CreateGraph +from backend.usecases.sample import create_test_graph, create_test_user +from backend.util.clients import get_scheduler_client +from backend.util.test import SpinTestServer + + +@pytest.mark.asyncio(loop_scope="session") +async def test_agent_schedule(server: SpinTestServer): + await db.connect() + test_user = await create_test_user() + test_graph = await server.agent_server.test_create_graph( + create_graph=CreateGraph(graph=create_test_graph()), + user_id=test_user.id, + ) + + scheduler = get_scheduler_client() + schedules = await scheduler.get_execution_schedules(test_graph.id, test_user.id) + assert len(schedules) == 0 + + schedule = await scheduler.add_execution_schedule( + graph_id=test_graph.id, + user_id=test_user.id, + graph_version=1, + cron="0 0 * * *", + input_data={"input": "data"}, + input_credentials={}, + ) + assert schedule + + schedules = await scheduler.get_execution_schedules(test_graph.id, test_user.id) + assert len(schedules) == 1 + assert schedules[0].cron == "0 0 * * *" + + await scheduler.delete_schedule(schedule.id, user_id=test_user.id) + schedules = await scheduler.get_execution_schedules( + test_graph.id, user_id=test_user.id + ) + assert len(schedules) == 0 diff --git a/autogpt_platform/backend/backend/executor/util_test.py b/autogpt_platform/backend/backend/executor/util_test.py new file mode 100644 index 000000000000..197daa223945 --- /dev/null +++ b/autogpt_platform/backend/backend/executor/util_test.py @@ -0,0 +1,278 @@ +from typing import cast + +import pytest + +from backend.executor.utils import merge_execution_input, parse_execution_output +from backend.util.mock import MockObject + + +def test_parse_execution_output(): + # Test case for basic output + output = ("result", "value") + assert parse_execution_output(output, "result") == "value" + + # Test case for list output + output = ("result", [10, 20, 30]) + assert parse_execution_output(output, "result_$_1") == 20 + + # Test case for dict output + output = ("result", {"key1": "value1", "key2": "value2"}) + assert parse_execution_output(output, "result_#_key1") == "value1" + + # Test case for object output + class Sample: + def __init__(self): + self.attr1 = "value1" + self.attr2 = "value2" + + output = ("result", Sample()) + assert parse_execution_output(output, "result_@_attr1") == "value1" + + # Test case for nested list output + output = ("result", [[1, 2], [3, 4]]) + assert parse_execution_output(output, "result_$_0_$_1") == 2 + assert parse_execution_output(output, "result_$_1_$_0") == 3 + + # Test case for list containing dict + output = ("result", [{"key1": "value1"}, {"key2": "value2"}]) + assert parse_execution_output(output, "result_$_0_#_key1") == "value1" + assert parse_execution_output(output, "result_$_1_#_key2") == "value2" + + # Test case for dict containing list + output = ("result", {"key1": [1, 2], "key2": [3, 4]}) + assert parse_execution_output(output, "result_#_key1_$_1") == 2 + assert parse_execution_output(output, "result_#_key2_$_0") == 3 + + # Test case for complex nested structure + class NestedSample: + def __init__(self): + self.attr1 = [1, 2] + self.attr2 = {"key": "value"} + + output = ("result", [NestedSample(), {"key": [1, 2]}]) + assert parse_execution_output(output, "result_$_0_@_attr1_$_1") == 2 + assert parse_execution_output(output, "result_$_0_@_attr2_#_key") == "value" + assert parse_execution_output(output, "result_$_1_#_key_$_0") == 1 + + # Test case for non-existent paths + output = ("result", [1, 2, 3]) + assert parse_execution_output(output, "result_$_5") is None + assert parse_execution_output(output, "result_#_key") is None + assert parse_execution_output(output, "result_@_attr") is None + assert parse_execution_output(output, "wrong_name") is None + + # Test cases for delimiter processing order + # Test case 1: List -> Dict -> List + output = ("result", [[{"key": [1, 2]}], [3, 4]]) + assert parse_execution_output(output, "result_$_0_$_0_#_key_$_1") == 2 + + # Test case 2: Dict -> List -> Object + class NestedObj: + def __init__(self): + self.value = "nested" + + output = ("result", {"key": [NestedObj(), 2]}) + assert parse_execution_output(output, "result_#_key_$_0_@_value") == "nested" + + # Test case 3: Object -> List -> Dict + class ParentObj: + def __init__(self): + self.items = [{"nested": "value"}] + + output = ("result", ParentObj()) + assert parse_execution_output(output, "result_@_items_$_0_#_nested") == "value" + + # Test case 4: Complex nested structure with all types + class ComplexObj: + def __init__(self): + self.data = [{"items": [{"value": "deep"}]}] + + output = ("result", {"key": [ComplexObj()]}) + assert ( + parse_execution_output( + output, "result_#_key_$_0_@_data_$_0_#_items_$_0_#_value" + ) + == "deep" + ) + + # Test case 5: Invalid paths that should return None + output = ("result", [{"key": [1, 2]}]) + assert parse_execution_output(output, "result_$_0_#_wrong_key") is None + assert parse_execution_output(output, "result_$_0_#_key_$_5") is None + assert parse_execution_output(output, "result_$_0_@_attr") is None + + # Test case 6: Mixed delimiter types in wrong order + output = ("result", {"key": [1, 2]}) + assert ( + parse_execution_output(output, "result_#_key_$_1_@_attr") is None + ) # Should fail at @_attr + assert ( + parse_execution_output(output, "result_@_attr_$_0_#_key") is None + ) # Should fail at @_attr + + +def test_merge_execution_input(): + # Test case for basic list extraction + data = { + "list_$_0": "a", + "list_$_1": "b", + } + result = merge_execution_input(data) + assert "list" in result + assert result["list"] == ["a", "b"] + + # Test case for basic dict extraction + data = { + "dict_#_key1": "value1", + "dict_#_key2": "value2", + } + result = merge_execution_input(data) + assert "dict" in result + assert result["dict"] == {"key1": "value1", "key2": "value2"} + + # Test case for object extraction + class Sample: + def __init__(self): + self.attr1 = None + self.attr2 = None + + data = { + "object_@_attr1": "value1", + "object_@_attr2": "value2", + } + result = merge_execution_input(data) + assert "object" in result + assert isinstance(result["object"], MockObject) + assert result["object"].attr1 == "value1" + assert result["object"].attr2 == "value2" + + # Test case for nested list extraction + data = { + "nested_list_$_0_$_0": "a", + "nested_list_$_0_$_1": "b", + "nested_list_$_1_$_0": "c", + } + result = merge_execution_input(data) + assert "nested_list" in result + assert result["nested_list"] == [["a", "b"], ["c"]] + + # Test case for list containing dict + data = { + "list_with_dict_$_0_#_key1": "value1", + "list_with_dict_$_0_#_key2": "value2", + "list_with_dict_$_1_#_key3": "value3", + } + result = merge_execution_input(data) + assert "list_with_dict" in result + assert result["list_with_dict"] == [ + {"key1": "value1", "key2": "value2"}, + {"key3": "value3"}, + ] + + # Test case for dict containing list + data = { + "dict_with_list_#_key1_$_0": "value1", + "dict_with_list_#_key1_$_1": "value2", + "dict_with_list_#_key2_$_0": "value3", + } + result = merge_execution_input(data) + assert "dict_with_list" in result + assert result["dict_with_list"] == { + "key1": ["value1", "value2"], + "key2": ["value3"], + } + + # Test case for complex nested structure + data = { + "complex_$_0_#_key1_$_0": "value1", + "complex_$_0_#_key1_$_1": "value2", + "complex_$_0_#_key2_@_attr1": "value3", + "complex_$_1_#_key3_$_0": "value4", + } + result = merge_execution_input(data) + assert "complex" in result + assert result["complex"][0]["key1"] == ["value1", "value2"] + assert isinstance(result["complex"][0]["key2"], MockObject) + assert result["complex"][0]["key2"].attr1 == "value3" + assert result["complex"][1]["key3"] == ["value4"] + + # Test case for invalid list index + data = {"list_$_invalid": "value"} + with pytest.raises(ValueError, match="index must be an integer"): + merge_execution_input(data) + + # Test cases for delimiter ordering + # Test case 1: List -> Dict -> List + data = { + "nested_$_0_#_key_$_0": "value1", + "nested_$_0_#_key_$_1": "value2", + } + result = merge_execution_input(data) + assert "nested" in result + assert result["nested"][0]["key"] == ["value1", "value2"] + + # Test case 2: Dict -> List -> Object + data = { + "nested_#_key_$_0_@_attr": "value1", + "nested_#_key_$_1_@_attr": "value2", + } + result = merge_execution_input(data) + assert "nested" in result + assert isinstance(result["nested"]["key"][0], MockObject) + assert result["nested"]["key"][0].attr == "value1" + assert result["nested"]["key"][1].attr == "value2" + + # Test case 3: Object -> List -> Dict + data = { + "nested_@_items_$_0_#_key": "value1", + "nested_@_items_$_1_#_key": "value2", + } + result = merge_execution_input(data) + assert "nested" in result + nested = result["nested"] + assert isinstance(nested, MockObject) + items = nested.items + assert isinstance(items, list) + assert items[0]["key"] == "value1" + assert items[1]["key"] == "value2" + + # Test case 4: Complex nested structure with all types + data = { + "deep_#_key_$_0_@_data_$_0_#_items_$_0_#_value": "deep_value", + "deep_#_key_$_0_@_data_$_1_#_items_$_0_#_value": "another_value", + } + result = merge_execution_input(data) + assert "deep" in result + deep_key = result["deep"]["key"][0] + assert deep_key is not None + data0 = getattr(deep_key, "data", None) + assert isinstance(data0, list) + # Check items0 + items0 = None + if len(data0) > 0 and isinstance(data0[0], dict) and "items" in data0[0]: + items0 = data0[0]["items"] + assert isinstance(items0, list) + items0 = cast(list, items0) + assert len(items0) > 0 + assert isinstance(items0[0], dict) + assert items0[0]["value"] == "deep_value" # type: ignore + # Check items1 + items1 = None + if len(data0) > 1 and isinstance(data0[1], dict) and "items" in data0[1]: + items1 = data0[1]["items"] + assert isinstance(items1, list) + items1 = cast(list, items1) + assert len(items1) > 0 + assert isinstance(items1[0], dict) + assert items1[0]["value"] == "another_value" # type: ignore + + # Test case 5: Mixed delimiter types in different orders + # the last one should replace the type + data = { + "mixed_$_0_#_key_@_attr": "value1", # List -> Dict -> Object + "mixed_#_key_$_0_@_attr": "value2", # Dict -> List -> Object + "mixed_@_attr_$_0_#_key": "value3", # Object -> List -> Dict + } + result = merge_execution_input(data) + assert "mixed" in result + assert result["mixed"].attr[0]["key"] == "value3" diff --git a/autogpt_platform/backend/backend/executor/utils.py b/autogpt_platform/backend/backend/executor/utils.py new file mode 100644 index 000000000000..dd4b94a60c37 --- /dev/null +++ b/autogpt_platform/backend/backend/executor/utils.py @@ -0,0 +1,1061 @@ +import asyncio +import logging +import threading +import time +from collections import defaultdict +from concurrent.futures import Future +from typing import Any, Optional + +from pydantic import BaseModel, JsonValue, ValidationError + +from backend.data import execution as execution_db +from backend.data import graph as graph_db +from backend.data.block import Block, BlockData, BlockInput, BlockType, get_block +from backend.data.block_cost_config import BLOCK_COSTS +from backend.data.cost import BlockCostType +from backend.data.db import prisma +from backend.data.execution import ( + ExecutionStatus, + GraphExecutionStats, + GraphExecutionWithNodes, + UserContext, +) +from backend.data.graph import GraphModel, Node +from backend.data.model import CredentialsMetaInput +from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig +from backend.data.user import get_user_by_id +from backend.util.clients import ( + get_async_execution_event_bus, + get_async_execution_queue, + get_database_manager_async_client, + get_integration_credentials_store, +) +from backend.util.exceptions import GraphValidationError, NotFoundError +from backend.util.logging import TruncatedLogger +from backend.util.mock import MockObject +from backend.util.settings import Config +from backend.util.type import convert + + +async def get_user_context(user_id: str) -> UserContext: + """ + Get UserContext for a user, always returns a valid context with timezone. + Defaults to UTC if user has no timezone set. + """ + user_context = UserContext(timezone="UTC") # Default to UTC + try: + user = await get_user_by_id(user_id) + if user and user.timezone and user.timezone != "not-set": + user_context.timezone = user.timezone + logger.debug(f"Retrieved user context: timezone={user.timezone}") + else: + logger.debug("User has no timezone set, using UTC") + except Exception as e: + logger.warning(f"Could not fetch user timezone: {e}") + # Continue with UTC as default + + return user_context + + +config = Config() +logger = TruncatedLogger(logging.getLogger(__name__), prefix="[GraphExecutorUtil]") + +# ============ Resource Helpers ============ # + + +class LogMetadata(TruncatedLogger): + def __init__( + self, + logger: logging.Logger, + user_id: str, + graph_eid: str, + graph_id: str, + node_eid: str, + node_id: str, + block_name: str, + max_length: int = 1000, + ): + metadata = { + "component": "ExecutionManager", + "user_id": user_id, + "graph_eid": graph_eid, + "graph_id": graph_id, + "node_eid": node_eid, + "node_id": node_id, + "block_name": block_name, + } + prefix = f"[ExecutionManager|uid:{user_id}|gid:{graph_id}|nid:{node_id}]|geid:{graph_eid}|neid:{node_eid}|{block_name}]" + super().__init__( + logger, + max_length=max_length, + prefix=prefix, + metadata=metadata, + ) + + +# ============ Execution Cost Helpers ============ # + + +def execution_usage_cost(execution_count: int) -> tuple[int, int]: + """ + Calculate the cost of executing a graph based on the current number of node executions. + + Args: + execution_count: Number of node executions + + Returns: + Tuple of cost amount and the number of execution count that is included in the cost. + """ + return ( + ( + config.execution_cost_per_threshold + if execution_count % config.execution_cost_count_threshold == 0 + else 0 + ), + config.execution_cost_count_threshold, + ) + + +def block_usage_cost( + block: Block, + input_data: BlockInput, + data_size: float = 0, + run_time: float = 0, +) -> tuple[int, BlockInput]: + """ + Calculate the cost of using a block based on the input data and the block type. + + Args: + block: Block object + input_data: Input data for the block + data_size: Size of the input data in bytes + run_time: Execution time of the block in seconds + + Returns: + Tuple of cost amount and cost filter + """ + block_costs = BLOCK_COSTS.get(type(block)) + if not block_costs: + return 0, {} + + for block_cost in block_costs: + if not _is_cost_filter_match(block_cost.cost_filter, input_data): + continue + + if block_cost.cost_type == BlockCostType.RUN: + return block_cost.cost_amount, block_cost.cost_filter + + if block_cost.cost_type == BlockCostType.SECOND: + return ( + int(run_time * block_cost.cost_amount), + block_cost.cost_filter, + ) + + if block_cost.cost_type == BlockCostType.BYTE: + return ( + int(data_size * block_cost.cost_amount), + block_cost.cost_filter, + ) + + return 0, {} + + +def _is_cost_filter_match(cost_filter: BlockInput, input_data: BlockInput) -> bool: + """ + Filter rules: + - If cost_filter is an object, then check if cost_filter is the subset of input_data + - Otherwise, check if cost_filter is equal to input_data. + - Undefined, null, and empty string are considered as equal. + """ + if not isinstance(cost_filter, dict) or not isinstance(input_data, dict): + return cost_filter == input_data + + return all( + (not input_data.get(k) and not v) + or (input_data.get(k) and _is_cost_filter_match(v, input_data[k])) + for k, v in cost_filter.items() + ) + + +# ============ Execution Input Helpers ============ # + +# --------------------------------------------------------------------------- # +# Delimiters +# --------------------------------------------------------------------------- # + +LIST_SPLIT = "_$_" +DICT_SPLIT = "_#_" +OBJC_SPLIT = "_@_" + +_DELIMS = (LIST_SPLIT, DICT_SPLIT, OBJC_SPLIT) + +# --------------------------------------------------------------------------- # +# Tokenisation utilities +# --------------------------------------------------------------------------- # + + +def _next_delim(s: str) -> tuple[str | None, int]: + """ + Return the *earliest* delimiter appearing in `s` and its index. + + If none present → (None, -1). + """ + first: str | None = None + pos = len(s) # sentinel: larger than any real index + for d in _DELIMS: + i = s.find(d) + if 0 <= i < pos: + first, pos = d, i + return first, (pos if first else -1) + + +def _tokenise(path: str) -> list[tuple[str, str]] | None: + """ + Convert the raw path string (starting with a delimiter) into + [ (delimiter, identifier), … ] or None if the syntax is malformed. + """ + tokens: list[tuple[str, str]] = [] + while path: + # 1. Which delimiter starts this chunk? + delim = next((d for d in _DELIMS if path.startswith(d)), None) + if delim is None: + return None # invalid syntax + + # 2. Slice off the delimiter, then up to the next delimiter (or EOS) + path = path[len(delim) :] + nxt_delim, pos = _next_delim(path) + token, path = ( + path[: pos if pos != -1 else len(path)], + path[pos if pos != -1 else len(path) :], + ) + if token == "": + return None # empty identifier is invalid + tokens.append((delim, token)) + return tokens + + +# --------------------------------------------------------------------------- # +# Public API – parsing (flattened ➜ concrete) +# --------------------------------------------------------------------------- # + + +def parse_execution_output(output: BlockData, name: str) -> Any | None: + """ + Retrieve a nested value out of `output` using the flattened *name*. + + On any failure (wrong name, wrong type, out-of-range, bad path) + returns **None**. + """ + base_name, data = output + + # Exact match → whole object + if name == base_name: + return data + + # Must start with the expected name + if not name.startswith(base_name): + return None + path = name[len(base_name) :] + if not path: + return None # nothing left to parse + + tokens = _tokenise(path) + if tokens is None: + return None + + cur: Any = data + for delim, ident in tokens: + if delim == LIST_SPLIT: + # list[index] + try: + idx = int(ident) + except ValueError: + return None + if not isinstance(cur, list) or idx >= len(cur): + return None + cur = cur[idx] + + elif delim == DICT_SPLIT: + if not isinstance(cur, dict) or ident not in cur: + return None + cur = cur[ident] + + elif delim == OBJC_SPLIT: + if not hasattr(cur, ident): + return None + cur = getattr(cur, ident) + + else: + return None # unreachable + + return cur + + +def _assign(container: Any, tokens: list[tuple[str, str]], value: Any) -> Any: + """ + Recursive helper that *returns* the (possibly new) container with + `value` assigned along the remaining `tokens` path. + """ + if not tokens: + return value # leaf reached + + delim, ident = tokens[0] + rest = tokens[1:] + + # ---------- list ---------- + if delim == LIST_SPLIT: + try: + idx = int(ident) + except ValueError: + raise ValueError("index must be an integer") + + if container is None: + container = [] + elif not isinstance(container, list): + container = list(container) if hasattr(container, "__iter__") else [] + + while len(container) <= idx: + container.append(None) + container[idx] = _assign(container[idx], rest, value) + return container + + # ---------- dict ---------- + if delim == DICT_SPLIT: + if container is None: + container = {} + elif not isinstance(container, dict): + container = dict(container) if hasattr(container, "items") else {} + container[ident] = _assign(container.get(ident), rest, value) + return container + + # ---------- object ---------- + if delim == OBJC_SPLIT: + if container is None or not isinstance(container, MockObject): + container = MockObject() + setattr( + container, + ident, + _assign(getattr(container, ident, None), rest, value), + ) + return container + + return value # unreachable + + +def merge_execution_input(data: BlockInput) -> BlockInput: + """ + Reconstruct nested objects from a *flattened* dict of key → value. + + Raises ValueError on syntactically invalid list indices. + """ + merged: BlockInput = {} + + for key, value in data.items(): + # Split off the base name (before the first delimiter, if any) + delim, pos = _next_delim(key) + if delim is None: + merged[key] = value + continue + + base, path = key[:pos], key[pos:] + tokens = _tokenise(path) + if tokens is None: + # Invalid key; treat as scalar under the raw name + merged[key] = value + continue + + merged[base] = _assign(merged.get(base), tokens, value) + + data.update(merged) + return data + + +def validate_exec( + node: Node, + data: BlockInput, + resolve_input: bool = True, +) -> tuple[BlockInput | None, str]: + """ + Validate the input data for a node execution. + + Args: + node: The node to execute. + data: The input data for the node execution. + resolve_input: Whether to resolve dynamic pins into dict/list/object. + + Returns: + A tuple of the validated data and the block name. + If the data is invalid, the first element will be None, and the second element + will be an error message. + If the data is valid, the first element will be the resolved input data, and + the second element will be the block name. + """ + node_block = get_block(node.block_id) + if not node_block: + return None, f"Block for {node.block_id} not found." + schema = node_block.input_schema + + # Input data (without default values) should contain all required fields. + error_prefix = f"Input data missing or mismatch for `{node_block.name}`:" + if missing_links := schema.get_missing_links(data, node.input_links): + return None, f"{error_prefix} unpopulated links {missing_links}" + + # Merge input data with default values and resolve dynamic dict/list/object pins. + input_default = schema.get_input_defaults(node.input_default) + data = {**input_default, **data} + if resolve_input: + data = merge_execution_input(data) + + # Convert non-matching data types to the expected input schema. + for name, data_type in schema.__annotations__.items(): + value = data.get(name) + if (value is not None) and (type(value) is not data_type): + data[name] = convert(value, data_type) + + # Input data post-merge should contain all required fields from the schema. + if missing_input := schema.get_missing_input(data): + return None, f"{error_prefix} missing input {missing_input}" + + # Last validation: Validate the input values against the schema. + if error := schema.get_mismatch_error(data): + error_message = f"{error_prefix} {error}" + logger.warning(error_message) + return None, error_message + + return data, node_block.name + + +async def _validate_node_input_credentials( + graph: GraphModel, + user_id: str, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, +) -> dict[str, dict[str, str]]: + """ + Checks all credentials for all nodes of the graph and returns structured errors. + + Returns: + dict[node_id, dict[field_name, error_message]]: Credential validation errors per node + """ + credential_errors: dict[str, dict[str, str]] = defaultdict(dict) + + for node in graph.nodes: + block = node.block + + # Find any fields of type CredentialsMetaInput + credentials_fields = block.input_schema.get_credentials_fields() + if not credentials_fields: + continue + + for field_name, credentials_meta_type in credentials_fields.items(): + try: + if ( + nodes_input_masks + and (node_input_mask := nodes_input_masks.get(node.id)) + and field_name in node_input_mask + ): + credentials_meta = credentials_meta_type.model_validate( + node_input_mask[field_name] + ) + elif field_name in node.input_default: + credentials_meta = credentials_meta_type.model_validate( + node.input_default[field_name] + ) + else: + # Missing credentials + credential_errors[node.id][ + field_name + ] = "These credentials are required" + continue + except ValidationError as e: + credential_errors[node.id][field_name] = f"Invalid credentials: {e}" + continue + + try: + # Fetch the corresponding Credentials and perform sanity checks + credentials = await get_integration_credentials_store().get_creds_by_id( + user_id, credentials_meta.id + ) + except Exception as e: + # Handle any errors fetching credentials + credential_errors[node.id][ + field_name + ] = f"Credentials not available: {e}" + continue + + if not credentials: + credential_errors[node.id][ + field_name + ] = f"Unknown credentials #{credentials_meta.id}" + continue + + if ( + credentials.provider != credentials_meta.provider + or credentials.type != credentials_meta.type + ): + logger.warning( + f"Invalid credentials #{credentials.id} for node #{node.id}: " + "type/provider mismatch: " + f"{credentials_meta.type}<>{credentials.type};" + f"{credentials_meta.provider}<>{credentials.provider}" + ) + credential_errors[node.id][ + field_name + ] = "Invalid credentials: type/provider mismatch" + continue + + return credential_errors + + +def make_node_credentials_input_map( + graph: GraphModel, + graph_credentials_input: dict[str, CredentialsMetaInput], +) -> dict[str, dict[str, JsonValue]]: + """ + Maps credentials for an execution to the correct nodes. + + Params: + graph: The graph to be executed. + graph_credentials_input: A (graph_input_name, credentials_meta) map. + + Returns: + dict[node_id, dict[field_name, CredentialsMetaRaw]]: Node credentials input map. + """ + result: dict[str, dict[str, JsonValue]] = {} + + # Get aggregated credentials fields for the graph + graph_cred_inputs = graph.aggregate_credentials_inputs() + + for graph_input_name, (_, compatible_node_fields) in graph_cred_inputs.items(): + # Best-effort map: skip missing items + if graph_input_name not in graph_credentials_input: + continue + + # Use passed-in credentials for all compatible node input fields + for node_id, node_field_name in compatible_node_fields: + if node_id not in result: + result[node_id] = {} + result[node_id][node_field_name] = graph_credentials_input[ + graph_input_name + ].model_dump(exclude_none=True) + + return result + + +async def validate_graph_with_credentials( + graph: GraphModel, + user_id: str, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, +) -> dict[str, dict[str, str]]: + """ + Validate graph including credentials and return structured errors per node. + + Returns: + dict[node_id, dict[field_name, error_message]]: Validation errors per node + """ + # Get input validation errors + node_input_errors = GraphModel.validate_graph_get_errors( + graph, for_run=True, nodes_input_masks=nodes_input_masks + ) + + # Get credential input/availability/validation errors + node_credential_input_errors = await _validate_node_input_credentials( + graph, user_id, nodes_input_masks + ) + + # Merge credential errors with structural errors + for node_id, field_errors in node_credential_input_errors.items(): + if node_id not in node_input_errors: + node_input_errors[node_id] = {} + node_input_errors[node_id].update(field_errors) + + return node_input_errors + + +async def _construct_starting_node_execution_input( + graph: GraphModel, + user_id: str, + graph_inputs: BlockInput, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, +) -> list[tuple[str, BlockInput]]: + """ + Validates and prepares the input data for executing a graph. + This function checks the graph for starting nodes, validates the input data + against the schema, and resolves dynamic input pins into a single list, + dictionary, or object. + + Args: + graph (GraphModel): The graph model to execute. + user_id (str): The ID of the user executing the graph. + data (BlockInput): The input data for the graph execution. + node_credentials_map: `dict[node_id, dict[input_name, CredentialsMetaInput]]` + + Returns: + list[tuple[str, BlockInput]]: A list of tuples, each containing the node ID and + the corresponding input data for that node. + """ + # Use new validation function that includes credentials + validation_errors = await validate_graph_with_credentials( + graph, user_id, nodes_input_masks + ) + n_error_nodes = len(validation_errors) + n_errors = sum(len(errors) for errors in validation_errors.values()) + if validation_errors: + raise GraphValidationError( + f"Graph validation failed: {n_errors} issues on {n_error_nodes} nodes", + node_errors=validation_errors, + ) + + nodes_input = [] + for node in graph.starting_nodes: + input_data = {} + block = node.block + + # Note block should never be executed. + if block.block_type == BlockType.NOTE: + continue + + # Extract request input data, and assign it to the input pin. + if block.block_type == BlockType.INPUT: + input_name = node.input_default.get("name") + if input_name and input_name in graph_inputs: + input_data = {"value": graph_inputs[input_name]} + + # Apply node input overrides + if nodes_input_masks and (node_input_mask := nodes_input_masks.get(node.id)): + input_data.update(node_input_mask) + + input_data, error = validate_exec(node, input_data) + if input_data is None: + raise ValueError(error) + else: + nodes_input.append((node.id, input_data)) + + if not nodes_input: + raise ValueError( + "No starting nodes found for the graph, make sure an AgentInput or blocks with no inbound links are present as starting nodes." + ) + + return nodes_input + + +async def validate_and_construct_node_execution_input( + graph_id: str, + user_id: str, + graph_inputs: BlockInput, + graph_version: Optional[int] = None, + graph_credentials_inputs: Optional[dict[str, CredentialsMetaInput]] = None, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, +) -> tuple[GraphModel, list[tuple[str, BlockInput]], dict[str, dict[str, JsonValue]]]: + """ + Public wrapper that handles graph fetching, credential mapping, and validation+construction. + This centralizes the logic used by both scheduler validation and actual execution. + + Args: + graph_id: The ID of the graph to validate/construct. + user_id: The ID of the user. + graph_inputs: The input data for the graph execution. + graph_version: The version of the graph to use. + graph_credentials_inputs: Credentials inputs to use. + nodes_input_masks: Node inputs to use. + + Returns: + tuple[GraphModel, list[tuple[str, BlockInput]]]: Graph model and list of tuples for node execution input. + + Raises: + NotFoundError: If the graph is not found. + GraphValidationError: If the graph has validation issues. + ValueError: If there are other validation errors. + """ + if prisma.is_connected(): + gdb = graph_db + else: + gdb = get_database_manager_async_client() + + graph: GraphModel | None = await gdb.get_graph( + graph_id=graph_id, + user_id=user_id, + version=graph_version, + include_subgraphs=True, + ) + if not graph: + raise NotFoundError(f"Graph #{graph_id} not found.") + + nodes_input_masks = _merge_nodes_input_masks( + ( + make_node_credentials_input_map(graph, graph_credentials_inputs) + if graph_credentials_inputs + else {} + ), + nodes_input_masks or {}, + ) + + starting_nodes_input = await _construct_starting_node_execution_input( + graph=graph, + user_id=user_id, + graph_inputs=graph_inputs, + nodes_input_masks=nodes_input_masks, + ) + + return graph, starting_nodes_input, nodes_input_masks + + +def _merge_nodes_input_masks( + overrides_map_1: dict[str, dict[str, JsonValue]], + overrides_map_2: dict[str, dict[str, JsonValue]], +) -> dict[str, dict[str, JsonValue]]: + """Perform a per-node merge of input overrides""" + result = overrides_map_1.copy() + for node_id, overrides2 in overrides_map_2.items(): + if node_id in result: + result[node_id] = {**result[node_id], **overrides2} + else: + result[node_id] = overrides2 + return result + + +# ============ Execution Queue Helpers ============ # + +GRAPH_EXECUTION_EXCHANGE = Exchange( + name="graph_execution", + type=ExchangeType.DIRECT, + durable=True, + auto_delete=False, +) +GRAPH_EXECUTION_QUEUE_NAME = "graph_execution_queue" +GRAPH_EXECUTION_ROUTING_KEY = "graph_execution.run" + +GRAPH_EXECUTION_CANCEL_EXCHANGE = Exchange( + name="graph_execution_cancel", + type=ExchangeType.FANOUT, + durable=True, + auto_delete=True, +) +GRAPH_EXECUTION_CANCEL_QUEUE_NAME = "graph_execution_cancel_queue" + +# Graceful shutdown timeout constants +# Agent executions can run for up to 1 day, so we need a graceful shutdown period +# that allows long-running executions to complete naturally +GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS = 24 * 60 * 60 # 1 day to complete active executions + + +def create_execution_queue_config() -> RabbitMQConfig: + """ + Define two exchanges and queues: + - 'graph_execution' (DIRECT) for run tasks. + - 'graph_execution_cancel' (FANOUT) for cancel requests. + """ + run_queue = Queue( + name=GRAPH_EXECUTION_QUEUE_NAME, + exchange=GRAPH_EXECUTION_EXCHANGE, + routing_key=GRAPH_EXECUTION_ROUTING_KEY, + durable=True, + auto_delete=False, + arguments={ + # x-consumer-timeout (1 week) + # Problem: Default 30-minute consumer timeout kills long-running graph executions + # Original error: "Consumer acknowledgement timed out after 1800000 ms (30 minutes)" + # Solution: Disable consumer timeout entirely - let graphs run indefinitely + # Safety: Heartbeat mechanism now handles dead consumer detection instead + # Use case: Graph executions that take hours to complete (AI model training, etc.) + "x-consumer-timeout": GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS + * 1000, + }, + ) + cancel_queue = Queue( + name=GRAPH_EXECUTION_CANCEL_QUEUE_NAME, + exchange=GRAPH_EXECUTION_CANCEL_EXCHANGE, + routing_key="", # not used for FANOUT + durable=True, + auto_delete=False, + ) + return RabbitMQConfig( + vhost="/", + exchanges=[GRAPH_EXECUTION_EXCHANGE, GRAPH_EXECUTION_CANCEL_EXCHANGE], + queues=[run_queue, cancel_queue], + ) + + +class CancelExecutionEvent(BaseModel): + graph_exec_id: str + + +async def stop_graph_execution( + user_id: str, + graph_exec_id: str, + wait_timeout: float = 15.0, +): + """ + Mechanism: + 1. Set the cancel event + 2. Graph executor's cancel handler thread detects the event, terminates workers, + reinitializes worker pool, and returns. + 3. Update execution statuses in DB and set `error` outputs to `"TERMINATED"`. + """ + queue_client = await get_async_execution_queue() + db = execution_db if prisma.is_connected() else get_database_manager_async_client() + await queue_client.publish_message( + routing_key="", + message=CancelExecutionEvent(graph_exec_id=graph_exec_id).model_dump_json(), + exchange=GRAPH_EXECUTION_CANCEL_EXCHANGE, + ) + + if not wait_timeout: + return + + start_time = time.time() + while time.time() - start_time < wait_timeout: + graph_exec = await db.get_graph_execution_meta( + execution_id=graph_exec_id, user_id=user_id + ) + + if not graph_exec: + raise NotFoundError(f"Graph execution #{graph_exec_id} not found.") + + if graph_exec.status in [ + ExecutionStatus.TERMINATED, + ExecutionStatus.COMPLETED, + ExecutionStatus.FAILED, + ]: + # If graph execution is terminated/completed/failed, cancellation is complete + await get_async_execution_event_bus().publish(graph_exec) + return + + if graph_exec.status in [ + ExecutionStatus.QUEUED, + ExecutionStatus.INCOMPLETE, + ]: + # If the graph is still on the queue, we can prevent them from being executed + # by setting the status to TERMINATED. + graph_exec.status = ExecutionStatus.TERMINATED + + await asyncio.gather( + # Update graph execution status + db.update_graph_execution_stats( + graph_exec_id=graph_exec.id, + status=ExecutionStatus.TERMINATED, + ), + # Publish graph execution event + get_async_execution_event_bus().publish(graph_exec), + ) + return + + if graph_exec.status == ExecutionStatus.RUNNING: + await asyncio.sleep(0.1) + + raise TimeoutError( + f"Graph execution #{graph_exec_id} will need to take longer than {wait_timeout} seconds to stop. " + f"You can check the status of the execution in the UI or try again later." + ) + + +async def add_graph_execution( + graph_id: str, + user_id: str, + inputs: Optional[BlockInput] = None, + preset_id: Optional[str] = None, + graph_version: Optional[int] = None, + graph_credentials_inputs: Optional[dict[str, CredentialsMetaInput]] = None, + nodes_input_masks: Optional[dict[str, dict[str, JsonValue]]] = None, +) -> GraphExecutionWithNodes: + """ + Adds a graph execution to the queue and returns the execution entry. + + Args: + graph_id: The ID of the graph to execute. + user_id: The ID of the user executing the graph. + inputs: The input data for the graph execution. + preset_id: The ID of the preset to use. + graph_version: The version of the graph to execute. + graph_credentials_inputs: Credentials inputs to use in the execution. + Keys should map to the keys generated by `GraphModel.aggregate_credentials_inputs`. + nodes_input_masks: Node inputs to use in the execution. + Returns: + GraphExecutionEntry: The entry for the graph execution. + Raises: + ValueError: If the graph is not found or if there are validation errors. + """ + if prisma.is_connected(): + edb = execution_db + else: + edb = get_database_manager_async_client() + + graph, starting_nodes_input, nodes_input_masks = ( + await validate_and_construct_node_execution_input( + graph_id=graph_id, + user_id=user_id, + graph_inputs=inputs or {}, + graph_version=graph_version, + graph_credentials_inputs=graph_credentials_inputs, + nodes_input_masks=nodes_input_masks, + ) + ) + graph_exec = None + + try: + graph_exec = await edb.create_graph_execution( + user_id=user_id, + graph_id=graph_id, + graph_version=graph.version, + starting_nodes_input=starting_nodes_input, + preset_id=preset_id, + ) + + # Fetch user context for the graph execution + user_context = await get_user_context(user_id) + + queue = await get_async_execution_queue() + graph_exec_entry = graph_exec.to_graph_execution_entry(user_context) + if nodes_input_masks: + graph_exec_entry.nodes_input_masks = nodes_input_masks + + logger.info( + f"Created graph execution #{graph_exec.id} for graph " + f"#{graph_id} with {len(starting_nodes_input)} starting nodes. " + f"Now publishing to execution queue." + ) + + await queue.publish_message( + routing_key=GRAPH_EXECUTION_ROUTING_KEY, + message=graph_exec_entry.model_dump_json(), + exchange=GRAPH_EXECUTION_EXCHANGE, + ) + logger.info(f"Published execution {graph_exec.id} to RabbitMQ queue") + + bus = get_async_execution_event_bus() + await bus.publish(graph_exec) + + return graph_exec + except BaseException as e: + err = str(e) or type(e).__name__ + if not graph_exec: + logger.error(f"Unable to execute graph #{graph_id} failed: {err}") + raise + + logger.error( + f"Unable to publish graph #{graph_id} exec #{graph_exec.id}: {err}" + ) + await edb.update_node_execution_status_batch( + [node_exec.node_exec_id for node_exec in graph_exec.node_executions], + ExecutionStatus.FAILED, + ) + await edb.update_graph_execution_stats( + graph_exec_id=graph_exec.id, + status=ExecutionStatus.FAILED, + stats=GraphExecutionStats(error=err), + ) + raise + + +# ============ Execution Output Helpers ============ # + + +class ExecutionOutputEntry(BaseModel): + node: Node + node_exec_id: str + data: BlockData + + +class NodeExecutionProgress: + def __init__(self): + self.output: dict[str, list[ExecutionOutputEntry]] = defaultdict(list) + self.tasks: dict[str, Future] = {} + self._lock = threading.Lock() + + def add_task(self, node_exec_id: str, task: Future): + self.tasks[node_exec_id] = task + + def add_output(self, output: ExecutionOutputEntry): + with self._lock: + self.output[output.node_exec_id].append(output) + + def pop_output(self) -> ExecutionOutputEntry | None: + exec_id = self._next_exec() + if not exec_id: + return None + + if self._pop_done_task(exec_id): + return self.pop_output() + + with self._lock: + if next_output := self.output[exec_id]: + return next_output.pop(0) + + return None + + def is_done(self, wait_time: float = 0.0) -> bool: + exec_id = self._next_exec() + if not exec_id: + return True + + if self._pop_done_task(exec_id): + return self.is_done(wait_time) + + if wait_time <= 0: + return False + + try: + self.tasks[exec_id].result(wait_time) + except TimeoutError: + pass + except Exception as e: + logger.error( + f"Task for exec ID {exec_id} failed with error: {e.__class__.__name__} {str(e)}" + ) + pass + return self.is_done(0) + + def stop(self) -> list[str]: + """ + Stops all tasks and clears the output. + This is useful for cleaning up when the execution is cancelled or terminated. + Returns a list of execution IDs that were stopped. + """ + cancelled_ids = [] + for task_id, task in self.tasks.items(): + if task.done(): + continue + task.cancel() + cancelled_ids.append(task_id) + return cancelled_ids + + def wait_for_done(self, timeout: float = 5.0): + """ + Wait for all cancelled tasks to complete cancellation. + + Args: + timeout: Maximum time to wait for cancellation in seconds + """ + start_time = time.time() + + while time.time() - start_time < timeout: + while self.pop_output(): + pass + + if self.is_done(): + return + + time.sleep(0.1) # Small delay to avoid busy waiting + + raise TimeoutError( + f"Timeout waiting for cancellation of tasks: {list(self.tasks.keys())}" + ) + + def _pop_done_task(self, exec_id: str) -> bool: + task = self.tasks.get(exec_id) + if not task: + return True + + if not task.done(): + return False + + with self._lock: + if self.output[exec_id]: + return False + + self.tasks.pop(exec_id) + return True + + def _next_exec(self) -> str | None: + if not self.tasks: + return None + return next(iter(self.tasks.keys())) diff --git a/autogpt_platform/backend/backend/integrations/ayrshare.py b/autogpt_platform/backend/backend/integrations/ayrshare.py new file mode 100644 index 000000000000..42e069b4aca5 --- /dev/null +++ b/autogpt_platform/backend/backend/integrations/ayrshare.py @@ -0,0 +1,515 @@ +from __future__ import annotations + +import json +import logging +from enum import Enum +from typing import Any, Optional + +from pydantic import BaseModel + +from backend.util.exceptions import MissingConfigError +from backend.util.request import Requests +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) + +settings = Settings() + + +class AyrshareAPIException(Exception): + def __init__(self, message: str, status_code: int): + super().__init__(message) + self.status_code = status_code + + +class SocialPlatform(str, Enum): + BLUESKY = "bluesky" + FACEBOOK = "facebook" + TWITTER = "twitter" + LINKEDIN = "linkedin" + INSTAGRAM = "instagram" + YOUTUBE = "youtube" + REDDIT = "reddit" + TELEGRAM = "telegram" + GOOGLE_MY_BUSINESS = "gmb" + PINTEREST = "pinterest" + TIKTOK = "tiktok" + SNAPCHAT = "snapchat" + THREADS = "threads" + + +class EmailConfig(BaseModel): + to: str + subject: Optional[str] = None + body: Optional[str] = None + from_name: Optional[str] = None + from_email: Optional[str] = None + + +class JWTResponse(BaseModel): + status: str + title: str + token: str + url: str + emailSent: Optional[bool] = None + expiresIn: Optional[str] = None + + +class ProfileResponse(BaseModel): + status: str + title: str + refId: str + profileKey: str + messagingActive: Optional[bool] = None + + +class PostResponse(BaseModel): + status: str + id: str + refId: str + profileTitle: str + post: str + postIds: Optional[list[PostIds]] = None + scheduleDate: Optional[str] = None + errors: Optional[list[str]] = None + + +class PostIds(BaseModel): + status: str + id: str + postUrl: str + platform: str + + +class AutoHashtag(BaseModel): + max: Optional[int] = None + position: Optional[str] = None + + +class FirstComment(BaseModel): + text: str + platforms: Optional[list[SocialPlatform]] = None + + +class AutoSchedule(BaseModel): + interval: str + platforms: Optional[list[SocialPlatform]] = None + startDate: Optional[str] = None + endDate: Optional[str] = None + + +class AutoRepost(BaseModel): + interval: str + platforms: Optional[list[SocialPlatform]] = None + startDate: Optional[str] = None + endDate: Optional[str] = None + + +class AyrshareClient: + """Client for the Ayrshare Social Media Post API""" + + API_URL = "https://api.ayrshare.com/api" + POST_ENDPOINT = f"{API_URL}/post" + PROFILES_ENDPOINT = f"{API_URL}/profiles" + JWT_ENDPOINT = f"{PROFILES_ENDPOINT}/generateJWT" + + def __init__( + self, + custom_requests: Optional[Requests] = None, + ): + if not settings.secrets.ayrshare_api_key: + raise MissingConfigError("AYRSHARE_API_KEY is not configured") + + headers: dict[str, str] = { + "Content-Type": "application/json", + "Authorization": f"Bearer {settings.secrets.ayrshare_api_key}", + } + self.headers = headers + + if custom_requests: + self._requests = custom_requests + else: + self._requests = Requests( + extra_headers=headers, + trusted_origins=["https://api.ayrshare.com"], + ) + + async def generate_jwt( + self, + private_key: str, + profile_key: str, + logout: Optional[bool] = None, + redirect: Optional[str] = None, + allowed_social: Optional[list[SocialPlatform]] = None, + verify: Optional[bool] = None, + base64: Optional[bool] = None, + expires_in: Optional[int] = None, + email: Optional[EmailConfig] = None, + ) -> JWTResponse: + """ + Generate a JSON Web Token (JWT) for use with single sign on. + + Docs: https://www.ayrshare.com/docs/apis/profiles/generate-jwt-overview + + Args: + domain: Domain of app. Must match the domain given during onboarding. + private_key: Private Key used for encryption. + profile_key: User Profile Key (not the API Key). + logout: Automatically logout the current session. + redirect: URL to redirect to when the "Done" button or logo is clicked. + allowed_social: List of social networks to display in the linking page. + verify: Verify that the generated token is valid (recommended for non-production). + base64: Whether the private key is base64 encoded. + expires_in: Token longevity in minutes (1-2880). + email: Configuration for sending Connect Accounts email. + + Returns: + JWTResponse object containing the JWT token and URL. + + Raises: + AyrshareAPIException: If the API request fails or private key is invalid. + """ + payload: dict[str, Any] = { + "domain": "id-pojeg", + "privateKey": private_key, + "profileKey": profile_key, + } + + headers = self.headers + headers["Profile-Key"] = profile_key + if logout is not None: + payload["logout"] = logout + if redirect is not None: + payload["redirect"] = redirect + if allowed_social is not None: + payload["allowedSocial"] = [p.value for p in allowed_social] + if verify is not None: + payload["verify"] = verify + if base64 is not None: + payload["base64"] = base64 + if expires_in is not None: + payload["expiresIn"] = expires_in + if email is not None: + payload["email"] = email.model_dump(exclude_none=True) + + response = await self._requests.post( + self.JWT_ENDPOINT, json=payload, headers=headers + ) + + if not response.ok: + try: + error_data = response.json() + error_message = error_data.get("message", "Unknown error") + except json.JSONDecodeError: + error_message = response.text() + + raise AyrshareAPIException( + f"Ayrshare API request failed ({response.status}): {error_message}", + response.status, + ) + + response_data = response.json() + if response_data.get("status") != "success": + raise AyrshareAPIException( + f"Ayrshare API returned error: {response_data.get('message', 'Unknown error')}", + response.status, + ) + + return JWTResponse(**response_data) + + async def create_profile( + self, + title: str, + messaging_active: Optional[bool] = None, + hide_top_header: Optional[bool] = None, + top_header: Optional[str] = None, + disable_social: Optional[list[SocialPlatform]] = None, + team: Optional[bool] = None, + email: Optional[str] = None, + sub_header: Optional[str] = None, + tags: Optional[list[str]] = None, + ) -> ProfileResponse: + """ + Create a new User Profile under your Primary Profile. + + Docs: https://www.ayrshare.com/docs/apis/profiles/create-profile + + Args: + title: Title of the new profile. Must be unique. + messaging_active: Set to true to activate messaging for this user profile. + hide_top_header: Hide the top header on the social accounts linkage page. + top_header: Change the header on the social accounts linkage page. + disable_social: Array of social networks that are disabled for this user's profile. + team: Create a new user profile as a team member. + email: Email address for team member invite (required if team is true). + sub_header: Change the sub header on the social accounts linkage page. + tags: Array of strings to tag user profiles. + + Returns: + ProfileResponse object containing the profile details and profile key. + + Raises: + AyrshareAPIException: If the API request fails or profile title already exists. + """ + payload: dict[str, Any] = { + "title": title, + } + + if messaging_active is not None: + payload["messagingActive"] = messaging_active + if hide_top_header is not None: + payload["hideTopHeader"] = hide_top_header + if top_header is not None: + payload["topHeader"] = top_header + if disable_social is not None: + payload["disableSocial"] = [p.value for p in disable_social] + if team is not None: + payload["team"] = team + if email is not None: + payload["email"] = email + if sub_header is not None: + payload["subHeader"] = sub_header + if tags is not None: + payload["tags"] = tags + + response = await self._requests.post(self.PROFILES_ENDPOINT, json=payload) + + if not response.ok: + try: + error_data = response.json() + error_message = error_data.get("message", "Unknown error") + except json.JSONDecodeError: + error_message = response.text() + + raise AyrshareAPIException( + f"Ayrshare API request failed ({response.status}): {error_message}", + response.status, + ) + + response_data = response.json() + if response_data.get("status") != "success": + raise AyrshareAPIException( + f"Ayrshare API returned error: {response_data.get('message', 'Unknown error')}", + response.status, + ) + + return ProfileResponse(**response_data) + + async def create_post( + self, + post: str, + platforms: list[SocialPlatform], + *, + media_urls: Optional[list[str]] = None, + is_video: Optional[bool] = None, + schedule_date: Optional[str] = None, + validate_schedule: Optional[bool] = None, + first_comment: Optional[FirstComment] = None, + disable_comments: Optional[bool] = None, + shorten_links: Optional[bool] = None, + auto_schedule: Optional[AutoSchedule] = None, + auto_repost: Optional[AutoRepost] = None, + auto_hashtag: Optional[AutoHashtag | bool] = None, + unsplash: Optional[str] = None, + bluesky_options: Optional[dict[str, Any]] = None, + facebook_options: Optional[dict[str, Any]] = None, + gmb_options: Optional[dict[str, Any]] = None, + instagram_options: Optional[dict[str, Any]] = None, + linkedin_options: Optional[dict[str, Any]] = None, + pinterest_options: Optional[dict[str, Any]] = None, + reddit_options: Optional[dict[str, Any]] = None, + snapchat_options: Optional[dict[str, Any]] = None, + telegram_options: Optional[dict[str, Any]] = None, + threads_options: Optional[dict[str, Any]] = None, + tiktok_options: Optional[dict[str, Any]] = None, + twitter_options: Optional[dict[str, Any]] = None, + youtube_options: Optional[dict[str, Any]] = None, + requires_approval: Optional[bool] = None, + random_post: Optional[bool] = None, + random_media_url: Optional[bool] = None, + idempotency_key: Optional[str] = None, + notes: Optional[str] = None, + profile_key: Optional[str] = None, + ) -> PostResponse: + """ + Create a post across multiple social media platforms. + + Docs: https://www.ayrshare.com/docs/apis/post/post + + Args: + post: The post text to be published - required + platforms: List of platforms to post to (e.g. [SocialPlatform.TWITTER, SocialPlatform.FACEBOOK]) - required + media_urls: Optional list of media URLs to include - required if is_video is true + is_video: Whether the media is a video - default is false (in api docs) + schedule_date: UTC datetime for scheduling (YYYY-MM-DDThh:mm:ssZ) - default is None (in api docs) + validate_schedule: Whether to validate the schedule date - default is false (in api docs) + first_comment: Configuration for first comment - default is None (in api docs) + disable_comments: Whether to disable comments - default is false (in api docs) + shorten_links: Whether to shorten links - default is false (in api docs) + auto_schedule: Configuration for automatic scheduling - default is None (in api docs https://www.ayrshare.com/docs/apis/auto-schedule/overview) + auto_repost: Configuration for automatic reposting - default is None (in api docs https://www.ayrshare.com/docs/apis/post/overview#auto-repost) + auto_hashtag: Configuration for automatic hashtags - default is None (in api docs https://www.ayrshare.com/docs/apis/post/overview#auto-hashtags) + unsplash: Unsplash image configuration - default is None (in api docs https://www.ayrshare.com/docs/apis/post/overview#unsplash) + + ------------------------------------------------------------ + + bluesky_options: Bluesky-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/bluesky + facebook_options: Facebook-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/facebook + gmb_options: Google Business Profile options - https://www.ayrshare.com/docs/apis/post/social-networks/google + instagram_options: Instagram-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/instagram + linkedin_options: LinkedIn-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/linkedin + pinterest_options: Pinterest-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/pinterest + reddit_options: Reddit-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/reddit + snapchat_options: Snapchat-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/snapchat + telegram_options: Telegram-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/telegram + threads_options: Threads-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/threads + tiktok_options: TikTok-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/tiktok + twitter_options: Twitter-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/twitter + youtube_options: YouTube-specific options - https://www.ayrshare.com/docs/apis/post/social-networks/youtube + + ------------------------------------------------------------ + + + requires_approval: Whether to enable approval workflow - default is false (in api docs) + random_post: Whether to generate random post text - default is false (in api docs) + random_media_url: Whether to generate random media - default is false (in api docs) + idempotency_key: Unique ID for the post - default is None (in api docs) + notes: Additional notes for the post - default is None (in api docs) + + Returns: + PostResponse object containing the post details and status + + Raises: + AyrshareAPIException: If the API request fails + """ + + payload: dict[str, Any] = { + "post": post, + "platforms": [p.value for p in platforms], + } + + # Add optional parameters if provided + if media_urls: + payload["mediaUrls"] = media_urls + if is_video is not None: + payload["isVideo"] = is_video + if schedule_date: + payload["scheduleDate"] = schedule_date + if validate_schedule is not None: + payload["validateSchedule"] = validate_schedule + if first_comment: + first_comment_dict = first_comment.model_dump(exclude_none=True) + if first_comment.platforms: + first_comment_dict["platforms"] = [ + p.value for p in first_comment.platforms + ] + payload["firstComment"] = first_comment_dict + if disable_comments is not None: + payload["disableComments"] = disable_comments + if shorten_links is not None: + payload["shortenLinks"] = shorten_links + if auto_schedule: + auto_schedule_dict = auto_schedule.model_dump(exclude_none=True) + if auto_schedule.platforms: + auto_schedule_dict["platforms"] = [ + p.value for p in auto_schedule.platforms + ] + payload["autoSchedule"] = auto_schedule_dict + if auto_repost: + auto_repost_dict = auto_repost.model_dump(exclude_none=True) + if auto_repost.platforms: + auto_repost_dict["platforms"] = [p.value for p in auto_repost.platforms] + payload["autoRepost"] = auto_repost_dict + if auto_hashtag: + payload["autoHashtag"] = ( + auto_hashtag.model_dump(exclude_none=True) + if isinstance(auto_hashtag, AutoHashtag) + else auto_hashtag + ) + if unsplash: + payload["unsplash"] = unsplash + if bluesky_options: + payload["blueskyOptions"] = bluesky_options + if facebook_options: + payload["faceBookOptions"] = facebook_options + if gmb_options: + payload["gmbOptions"] = gmb_options + if instagram_options: + payload["instagramOptions"] = instagram_options + if linkedin_options: + payload["linkedInOptions"] = linkedin_options + if pinterest_options: + payload["pinterestOptions"] = pinterest_options + if reddit_options: + payload["redditOptions"] = reddit_options + if snapchat_options: + payload["snapchatOptions"] = snapchat_options + if telegram_options: + payload["telegramOptions"] = telegram_options + if threads_options: + payload["threadsOptions"] = threads_options + if tiktok_options: + payload["tikTokOptions"] = tiktok_options + if twitter_options: + payload["twitterOptions"] = twitter_options + if youtube_options: + payload["youTubeOptions"] = youtube_options + if requires_approval is not None: + payload["requiresApproval"] = requires_approval + if random_post is not None: + payload["randomPost"] = random_post + if random_media_url is not None: + payload["randomMediaUrl"] = random_media_url + if idempotency_key: + payload["idempotencyKey"] = idempotency_key + if notes: + payload["notes"] = notes + + headers = self.headers + if profile_key: + headers["Profile-Key"] = profile_key + + response = await self._requests.post( + self.POST_ENDPOINT, json=payload, headers=headers + ) + logger.warning(f"Ayrshare request: {payload} and headers: {headers}") + if not response.ok: + logger.error( + f"Ayrshare API request failed ({response.status}): {response.text()}" + ) + try: + error_data = response.json() + error_message = error_data.get("message", "Unknown error") + except json.JSONDecodeError: + error_message = response.text() + + raise AyrshareAPIException( + f"Ayrshare API request failed ({response.status}): {error_message}", + response.status, + ) + + response_data = response.json() + if response_data.get("status") != "success": + logger.error( + f"Ayrshare API returned error: {response_data.get('message', 'Unknown error')}" + ) + raise AyrshareAPIException( + f"Ayrshare API returned error: {response_data.get('message', 'Unknown error')}", + response.status, + ) + + # Ayrshare returns an array of posts even for single posts + # It seems like there is only ever one post in the array, and within that + # there are multiple postIds + + # There is a seperate endpoint for bulk posting, so feels safe to just take + # the first post from the array + + if len(response_data["posts"]) == 0: + logger.error("Ayrshare API returned no posts") + raise AyrshareAPIException( + "Ayrshare API returned no posts", + response.status, + ) + logger.warn(f"Ayrshare API returned posts: {response_data['posts']}") + return PostResponse(**response_data["posts"][0]) diff --git a/autogpt_platform/backend/backend/integrations/credentials_store.py b/autogpt_platform/backend/backend/integrations/credentials_store.py index 7d539b73c476..75ae346d5db4 100644 --- a/autogpt_platform/backend/backend/integrations/credentials_store.py +++ b/autogpt_platform/backend/backend/integrations/credentials_store.py @@ -1,15 +1,14 @@ +import base64 +import hashlib import secrets +from contextlib import asynccontextmanager from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING +from typing import Optional +from autogpt_libs.utils.synchronize import AsyncRedisKeyedMutex from pydantic import SecretStr -if TYPE_CHECKING: - from backend.executor.database import DatabaseManager - -from autogpt_libs.utils.cache import thread_cached -from autogpt_libs.utils.synchronize import RedisKeyedMutex - +from backend.data.db import prisma from backend.data.model import ( APIKeyCredentials, Credentials, @@ -17,10 +16,20 @@ OAuthState, UserIntegrations, ) +from backend.data.redis_client import get_redis_async from backend.util.settings import Settings settings = Settings() +# This is an overrride since ollama doesn't actually require an API key, but the creddential system enforces one be attached +ollama_credentials = APIKeyCredentials( + id="744fdc56-071a-4761-b5a5-0af0ce10a2b5", + provider="ollama", + api_key=SecretStr("FAKE_API_KEY"), + title="Use Credits for Ollama", + expires_at=None, +) + revid_credentials = APIKeyCredentials( id="fdb7f412-f519-48d1-9b5f-d2f73d0e01fe", provider="revid", @@ -49,6 +58,13 @@ title="Use Credits for OpenAI", expires_at=None, ) +aiml_api_credentials = APIKeyCredentials( + id="aad82a89-9794-4ebb-977f-d736aa5260a3", + provider="aiml_api", + api_key=SecretStr(settings.secrets.aiml_api_key), + title="Use Credits for AI/ML API", + expires_at=None, +) anthropic_credentials = APIKeyCredentials( id="24e5d942-d9e3-4798-8151-90143ee55629", provider="anthropic", @@ -91,50 +107,176 @@ title="Use Credits for Open Router", expires_at=None, ) +fal_credentials = APIKeyCredentials( + id="6c0f5bd0-9008-4638-9d79-4b40b631803e", + provider="fal", + api_key=SecretStr(settings.secrets.fal_api_key), + title="Use Credits for FAL", + expires_at=None, +) +exa_credentials = APIKeyCredentials( + id="96153e04-9c6c-4486-895f-5bb683b1ecec", + provider="exa", + api_key=SecretStr(settings.secrets.exa_api_key), + title="Use Credits for Exa search", + expires_at=None, +) +e2b_credentials = APIKeyCredentials( + id="78d19fd7-4d59-4a16-8277-3ce310acf2b7", + provider="e2b", + api_key=SecretStr(settings.secrets.e2b_api_key), + title="Use Credits for E2B", + expires_at=None, +) +nvidia_credentials = APIKeyCredentials( + id="96b83908-2789-4dec-9968-18f0ece4ceb3", + provider="nvidia", + api_key=SecretStr(settings.secrets.nvidia_api_key), + title="Use Credits for Nvidia", + expires_at=None, +) +screenshotone_credentials = APIKeyCredentials( + id="3b1bdd16-8818-4bc2-8cbb-b23f9a3439ed", + provider="screenshotone", + api_key=SecretStr(settings.secrets.screenshotone_api_key), + title="Use Credits for ScreenshotOne", + expires_at=None, +) +mem0_credentials = APIKeyCredentials( + id="ed55ac19-356e-4243-a6cb-bc599e9b716f", + provider="mem0", + api_key=SecretStr(settings.secrets.mem0_api_key), + title="Use Credits for Mem0", + expires_at=None, +) + +apollo_credentials = APIKeyCredentials( + id="544c62b5-1d0f-4156-8fb4-9525f11656eb", + provider="apollo", + api_key=SecretStr(settings.secrets.apollo_api_key), + title="Use Credits for Apollo", + expires_at=None, +) + +smartlead_credentials = APIKeyCredentials( + id="3bcdbda3-84a3-46af-8fdb-bfd2472298b8", + provider="smartlead", + api_key=SecretStr(settings.secrets.smartlead_api_key), + title="Use Credits for SmartLead", + expires_at=None, +) + +google_maps_credentials = APIKeyCredentials( + id="9aa1bde0-4947-4a70-a20c-84daa3850d52", + provider="google_maps", + api_key=SecretStr(settings.secrets.google_maps_api_key), + title="Use Credits for Google Maps", + expires_at=None, +) +zerobounce_credentials = APIKeyCredentials( + id="63a6e279-2dc2-448e-bf57-85776f7176dc", + provider="zerobounce", + api_key=SecretStr(settings.secrets.zerobounce_api_key), + title="Use Credits for ZeroBounce", + expires_at=None, +) + +enrichlayer_credentials = APIKeyCredentials( + id="d9fce73a-6c1d-4e8b-ba2e-12a456789def", + provider="enrichlayer", + api_key=SecretStr(settings.secrets.enrichlayer_api_key), + title="Use Credits for Enrichlayer", + expires_at=None, +) + + +llama_api_credentials = APIKeyCredentials( + id="d44045af-1c33-4833-9e19-752313214de2", + provider="llama_api", + api_key=SecretStr(settings.secrets.llama_api_key), + title="Use Credits for Llama API", + expires_at=None, +) + +v0_credentials = APIKeyCredentials( + id="c4e6d1a0-3b5f-4789-a8e2-9b123456789f", + provider="v0", + api_key=SecretStr(settings.secrets.v0_api_key), + title="Use Credits for v0 by Vercel", + expires_at=None, +) DEFAULT_CREDENTIALS = [ + ollama_credentials, revid_credentials, ideogram_credentials, replicate_credentials, openai_credentials, + aiml_api_credentials, anthropic_credentials, groq_credentials, did_credentials, jina_credentials, unreal_credentials, open_router_credentials, + enrichlayer_credentials, + fal_credentials, + exa_credentials, + e2b_credentials, + mem0_credentials, + nvidia_credentials, + screenshotone_credentials, + apollo_credentials, + smartlead_credentials, + zerobounce_credentials, + google_maps_credentials, + llama_api_credentials, + v0_credentials, ] class IntegrationCredentialsStore: def __init__(self): - from backend.data.redis import get_redis + self._locks = None - self.locks = RedisKeyedMutex(get_redis()) + async def locks(self) -> AsyncRedisKeyedMutex: + if self._locks: + return self._locks + + self._locks = AsyncRedisKeyedMutex(await get_redis_async()) + return self._locks @property - @thread_cached - def db_manager(self) -> "DatabaseManager": - from backend.executor.database import DatabaseManager - from backend.util.service import get_service_client + def db_manager(self): + if prisma.is_connected(): + from backend.data import user + + return user + else: + from backend.util.clients import get_database_manager_async_client - return get_service_client(DatabaseManager) + return get_database_manager_async_client() - def add_creds(self, user_id: str, credentials: Credentials) -> None: - with self.locked_user_integrations(user_id): - if self.get_creds_by_id(user_id, credentials.id): + # =============== USER-MANAGED CREDENTIALS =============== # + async def add_creds(self, user_id: str, credentials: Credentials) -> None: + async with await self.locked_user_integrations(user_id): + if await self.get_creds_by_id(user_id, credentials.id): raise ValueError( f"Can not re-create existing credentials #{credentials.id} " f"for user #{user_id}" ) - self._set_user_integration_creds( - user_id, [*self.get_all_creds(user_id), credentials] + await self._set_user_integration_creds( + user_id, [*(await self.get_all_creds(user_id)), credentials] ) - def get_all_creds(self, user_id: str) -> list[Credentials]: - users_credentials = self._get_user_integrations(user_id).credentials + async def get_all_creds(self, user_id: str) -> list[Credentials]: + users_credentials = (await self._get_user_integrations(user_id)).credentials all_credentials = users_credentials + # These will always be added + all_credentials.append(ollama_credentials) + + # These will only be added if the API key is set if settings.secrets.revid_api_key: all_credentials.append(revid_credentials) if settings.secrets.ideogram_api_key: @@ -145,6 +287,8 @@ def get_all_creds(self, user_id: str) -> list[Credentials]: all_credentials.append(replicate_credentials) if settings.secrets.openai_api_key: all_credentials.append(openai_credentials) + if settings.secrets.aiml_api_key: + all_credentials.append(aiml_api_credentials) if settings.secrets.anthropic_api_key: all_credentials.append(anthropic_credentials) if settings.secrets.did_api_key: @@ -155,23 +299,49 @@ def get_all_creds(self, user_id: str) -> list[Credentials]: all_credentials.append(unreal_credentials) if settings.secrets.open_router_api_key: all_credentials.append(open_router_credentials) + if settings.secrets.enrichlayer_api_key: + all_credentials.append(enrichlayer_credentials) + if settings.secrets.fal_api_key: + all_credentials.append(fal_credentials) + if settings.secrets.exa_api_key: + all_credentials.append(exa_credentials) + if settings.secrets.e2b_api_key: + all_credentials.append(e2b_credentials) + if settings.secrets.nvidia_api_key: + all_credentials.append(nvidia_credentials) + if settings.secrets.screenshotone_api_key: + all_credentials.append(screenshotone_credentials) + if settings.secrets.mem0_api_key: + all_credentials.append(mem0_credentials) + if settings.secrets.apollo_api_key: + all_credentials.append(apollo_credentials) + if settings.secrets.smartlead_api_key: + all_credentials.append(smartlead_credentials) + if settings.secrets.zerobounce_api_key: + all_credentials.append(zerobounce_credentials) + if settings.secrets.google_maps_api_key: + all_credentials.append(google_maps_credentials) return all_credentials - def get_creds_by_id(self, user_id: str, credentials_id: str) -> Credentials | None: - all_credentials = self.get_all_creds(user_id) + async def get_creds_by_id( + self, user_id: str, credentials_id: str + ) -> Credentials | None: + all_credentials = await self.get_all_creds(user_id) return next((c for c in all_credentials if c.id == credentials_id), None) - def get_creds_by_provider(self, user_id: str, provider: str) -> list[Credentials]: - credentials = self.get_all_creds(user_id) + async def get_creds_by_provider( + self, user_id: str, provider: str + ) -> list[Credentials]: + credentials = await self.get_all_creds(user_id) return [c for c in credentials if c.provider == provider] - def get_authorized_providers(self, user_id: str) -> list[str]: - credentials = self.get_all_creds(user_id) + async def get_authorized_providers(self, user_id: str) -> list[str]: + credentials = await self.get_all_creds(user_id) return list(set(c.provider for c in credentials)) - def update_creds(self, user_id: str, updated: Credentials) -> None: - with self.locked_user_integrations(user_id): - current = self.get_creds_by_id(user_id, updated.id) + async def update_creds(self, user_id: str, updated: Credentials) -> None: + async with await self.locked_user_integrations(user_id): + current = await self.get_creds_by_id(user_id, updated.id) if not current: raise ValueError( f"Credentials with ID {updated.id} " @@ -199,73 +369,82 @@ def update_creds(self, user_id: str, updated: Credentials) -> None: # Update the credentials updated_credentials_list = [ updated if c.id == updated.id else c - for c in self.get_all_creds(user_id) + for c in await self.get_all_creds(user_id) ] - self._set_user_integration_creds(user_id, updated_credentials_list) + await self._set_user_integration_creds(user_id, updated_credentials_list) - def delete_creds_by_id(self, user_id: str, credentials_id: str) -> None: - with self.locked_user_integrations(user_id): + async def delete_creds_by_id(self, user_id: str, credentials_id: str) -> None: + async with await self.locked_user_integrations(user_id): filtered_credentials = [ - c for c in self.get_all_creds(user_id) if c.id != credentials_id + c for c in await self.get_all_creds(user_id) if c.id != credentials_id ] - self._set_user_integration_creds(user_id, filtered_credentials) + await self._set_user_integration_creds(user_id, filtered_credentials) + + # ============== SYSTEM-MANAGED CREDENTIALS ============== # + + async def set_ayrshare_profile_key(self, user_id: str, profile_key: str) -> None: + """Set the Ayrshare profile key for a user. + + The profile key is used to authenticate API requests to Ayrshare's social media posting service. + See https://www.ayrshare.com/docs/apis/profiles/overview for more details. + + Args: + user_id: The ID of the user to set the profile key for + profile_key: The profile key to set + """ + _profile_key = SecretStr(profile_key) + async with self.edit_user_integrations(user_id) as user_integrations: + user_integrations.managed_credentials.ayrshare_profile_key = _profile_key - def store_state_token(self, user_id: str, provider: str, scopes: list[str]) -> str: + # ===================== OAUTH STATES ===================== # + + async def store_state_token( + self, user_id: str, provider: str, scopes: list[str], use_pkce: bool = False + ) -> tuple[str, str]: token = secrets.token_urlsafe(32) expires_at = datetime.now(timezone.utc) + timedelta(minutes=10) + (code_challenge, code_verifier) = self._generate_code_challenge() + state = OAuthState( token=token, provider=provider, + code_verifier=code_verifier, expires_at=int(expires_at.timestamp()), scopes=scopes, ) - with self.locked_user_integrations(user_id): - user_integrations = self._get_user_integrations(user_id) + async with self.edit_user_integrations(user_id) as user_integrations: + user_integrations.oauth_states.append(state) + + async with await self.locked_user_integrations(user_id): + + user_integrations = await self._get_user_integrations(user_id) oauth_states = user_integrations.oauth_states oauth_states.append(state) user_integrations.oauth_states = oauth_states - self.db_manager.update_user_integrations( + await self.db_manager.update_user_integrations( user_id=user_id, data=user_integrations ) - return token + return token, code_challenge - def get_any_valid_scopes_from_state_token( - self, user_id: str, token: str, provider: str - ) -> list[str]: + def _generate_code_challenge(self) -> tuple[str, str]: """ - Get the valid scopes from the OAuth state token. This will return any valid scopes - from any OAuth state token for the given provider. If no valid scopes are found, - an empty list is returned. DO NOT RELY ON THIS TOKEN TO AUTHENTICATE A USER, AS IT - IS TO CHECK IF THE USER HAS GIVEN PERMISSIONS TO THE APPLICATION BEFORE EXCHANGING - THE CODE FOR TOKENS. + Generate code challenge using SHA256 from the code verifier. + Currently only SHA256 is supported.(In future if we want to support more methods we can add them here) """ - user_integrations = self._get_user_integrations(user_id) - oauth_states = user_integrations.oauth_states - - now = datetime.now(timezone.utc) - valid_state = next( - ( - state - for state in oauth_states - if state.token == token - and state.provider == provider - and state.expires_at > now.timestamp() - ), - None, - ) - - if valid_state: - return valid_state.scopes - - return [] + code_verifier = secrets.token_urlsafe(96) + sha256_hash = hashlib.sha256(code_verifier.encode("utf-8")).digest() + code_challenge = base64.urlsafe_b64encode(sha256_hash).decode("utf-8") + return code_challenge.replace("=", ""), code_verifier - def verify_state_token(self, user_id: str, token: str, provider: str) -> bool: - with self.locked_user_integrations(user_id): - user_integrations = self._get_user_integrations(user_id) + async def verify_state_token( + self, user_id: str, token: str, provider: str + ) -> Optional[OAuthState]: + async with await self.locked_user_integrations(user_id): + user_integrations = await self._get_user_integrations(user_id) oauth_states = user_integrations.oauth_states now = datetime.now(timezone.utc) @@ -273,7 +452,7 @@ def verify_state_token(self, user_id: str, token: str, provider: str) -> bool: ( state for state in oauth_states - if state.token == token + if secrets.compare_digest(state.token, token) and state.provider == provider and state.expires_at > now.timestamp() ), @@ -284,26 +463,37 @@ def verify_state_token(self, user_id: str, token: str, provider: str) -> bool: # Remove the used state oauth_states.remove(valid_state) user_integrations.oauth_states = oauth_states - self.db_manager.update_user_integrations(user_id, user_integrations) - return True + await self.db_manager.update_user_integrations( + user_id, user_integrations + ) + return valid_state - return False + return None - def _set_user_integration_creds( + # =================== GET/SET HELPERS =================== # + + @asynccontextmanager + async def edit_user_integrations(self, user_id: str): + async with await self.locked_user_integrations(user_id): + user_integrations = await self._get_user_integrations(user_id) + yield user_integrations # yield to allow edits + await self.db_manager.update_user_integrations( + user_id=user_id, data=user_integrations + ) + + async def _set_user_integration_creds( self, user_id: str, credentials: list[Credentials] ) -> None: - integrations = self._get_user_integrations(user_id) + integrations = await self._get_user_integrations(user_id) # Remove default credentials from the list credentials = [c for c in credentials if c not in DEFAULT_CREDENTIALS] integrations.credentials = credentials - self.db_manager.update_user_integrations(user_id, integrations) + await self.db_manager.update_user_integrations(user_id, integrations) - def _get_user_integrations(self, user_id: str) -> UserIntegrations: - integrations: UserIntegrations = self.db_manager.get_user_integrations( - user_id=user_id - ) - return integrations + async def _get_user_integrations(self, user_id: str) -> UserIntegrations: + return await self.db_manager.get_user_integrations(user_id=user_id) - def locked_user_integrations(self, user_id: str): + async def locked_user_integrations(self, user_id: str): key = (f"user:{user_id}", "integrations") - return self.locks.locked(key) + locks = await self.locks() + return locks.locked(key) diff --git a/autogpt_platform/backend/backend/integrations/creds_manager.py b/autogpt_platform/backend/backend/integrations/creds_manager.py index 17633bd58721..e5ae0cdb72e9 100644 --- a/autogpt_platform/backend/backend/integrations/creds_manager.py +++ b/autogpt_platform/backend/backend/integrations/creds_manager.py @@ -1,15 +1,17 @@ import logging -from contextlib import contextmanager +import os +from contextlib import asynccontextmanager from datetime import datetime -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Callable, Coroutine -from autogpt_libs.utils.synchronize import RedisKeyedMutex -from redis.lock import Lock as RedisLock +from autogpt_libs.utils.synchronize import AsyncRedisKeyedMutex +from redis.asyncio.lock import Lock as AsyncRedisLock -from backend.data import redis -from backend.data.model import Credentials +from backend.data.model import Credentials, OAuth2Credentials +from backend.data.redis_client import get_redis_async from backend.integrations.credentials_store import IntegrationCredentialsStore -from backend.integrations.oauth import HANDLERS_BY_NAME +from backend.integrations.oauth import CREDENTIALS_BY_PROVIDER, HANDLERS_BY_NAME +from backend.integrations.providers import ProviderName from backend.util.exceptions import MissingConfigError from backend.util.settings import Settings @@ -53,20 +55,26 @@ class IntegrationCredentialsManager: """ def __init__(self): - redis_conn = redis.get_redis() - self._locks = RedisKeyedMutex(redis_conn) self.store = IntegrationCredentialsStore() + self._locks = None - def create(self, user_id: str, credentials: Credentials) -> None: - return self.store.add_creds(user_id, credentials) + async def locks(self) -> AsyncRedisKeyedMutex: + if self._locks: + return self._locks - def exists(self, user_id: str, credentials_id: str) -> bool: - return self.store.get_creds_by_id(user_id, credentials_id) is not None + self._locks = AsyncRedisKeyedMutex(await get_redis_async()) + return self._locks - def get( + async def create(self, user_id: str, credentials: Credentials) -> None: + return await self.store.add_creds(user_id, credentials) + + async def exists(self, user_id: str, credentials_id: str) -> bool: + return (await self.store.get_creds_by_id(user_id, credentials_id)) is not None + + async def get( self, user_id: str, credentials_id: str, lock: bool = True ) -> Credentials | None: - credentials = self.store.get_creds_by_id(user_id, credentials_id) + credentials = await self.store.get_creds_by_id(user_id, credentials_id) if not credentials: return None @@ -77,33 +85,15 @@ def get( f"{datetime.fromtimestamp(credentials.access_token_expires_at)}; " f"current time is {datetime.now()}" ) - - with self._locked(user_id, credentials_id, "refresh"): - oauth_handler = _get_provider_oauth_handler(credentials.provider) - if oauth_handler.needs_refresh(credentials): - logger.debug( - f"Refreshing '{credentials.provider}' " - f"credentials #{credentials.id}" - ) - _lock = None - if lock: - # Wait until the credentials are no longer in use anywhere - _lock = self._acquire_lock(user_id, credentials_id) - - fresh_credentials = oauth_handler.refresh_tokens(credentials) - self.store.update_creds(user_id, fresh_credentials) - if _lock: - _lock.release() - - credentials = fresh_credentials + credentials = await self.refresh_if_needed(user_id, credentials, lock) else: logger.debug(f"Credentials #{credentials.id} never expire") return credentials - def acquire( + async def acquire( self, user_id: str, credentials_id: str - ) -> tuple[Credentials, RedisLock]: + ) -> tuple[Credentials, AsyncRedisLock]: """ ⚠️ WARNING: this locks credentials system-wide and blocks both acquiring and updating them elsewhere until the lock is released. @@ -111,53 +101,121 @@ def acquire( """ # Use a low-priority (!time_sensitive) locking queue on top of the general lock # to allow priority access for refreshing/updating the tokens. - with self._locked(user_id, credentials_id, "!time_sensitive"): - lock = self._acquire_lock(user_id, credentials_id) - credentials = self.get(user_id, credentials_id, lock=False) + async with self._locked(user_id, credentials_id, "!time_sensitive"): + lock = await self._acquire_lock(user_id, credentials_id) + credentials = await self.get(user_id, credentials_id, lock=False) if not credentials: raise ValueError( f"Credentials #{credentials_id} for user #{user_id} not found" ) return credentials, lock - def update(self, user_id: str, updated: Credentials) -> None: - with self._locked(user_id, updated.id): - self.store.update_creds(user_id, updated) + def cached_getter( + self, user_id: str + ) -> Callable[[str], "Coroutine[Any, Any, Credentials | None]"]: + all_credentials = None + + async def get_credentials(creds_id: str) -> "Credentials | None": + nonlocal all_credentials + if not all_credentials: + # Fetch credentials on first necessity + all_credentials = await self.store.get_all_creds(user_id) + + credential = next((c for c in all_credentials if c.id == creds_id), None) + if not credential: + return None + if credential.type != "oauth2" or not credential.access_token_expires_at: + # Credential doesn't expire + return credential + + # Credential is OAuth2 credential and has expiration timestamp + return await self.refresh_if_needed(user_id, credential) + + return get_credentials + + async def refresh_if_needed( + self, user_id: str, credentials: OAuth2Credentials, lock: bool = True + ) -> OAuth2Credentials: + async with self._locked(user_id, credentials.id, "refresh"): + oauth_handler = await _get_provider_oauth_handler(credentials.provider) + if oauth_handler.needs_refresh(credentials): + logger.debug( + f"Refreshing '{credentials.provider}' " + f"credentials #{credentials.id}" + ) + _lock = None + if lock: + # Wait until the credentials are no longer in use anywhere + _lock = await self._acquire_lock(user_id, credentials.id) + + fresh_credentials = await oauth_handler.refresh_tokens(credentials) + await self.store.update_creds(user_id, fresh_credentials) + if _lock and (await _lock.locked()) and (await _lock.owned()): + await _lock.release() + + credentials = fresh_credentials + return credentials + + async def update(self, user_id: str, updated: Credentials) -> None: + async with self._locked(user_id, updated.id): + await self.store.update_creds(user_id, updated) - def delete(self, user_id: str, credentials_id: str) -> None: - with self._locked(user_id, credentials_id): - self.store.delete_creds_by_id(user_id, credentials_id) + async def delete(self, user_id: str, credentials_id: str) -> None: + async with self._locked(user_id, credentials_id): + await self.store.delete_creds_by_id(user_id, credentials_id) # -- Locking utilities -- # - def _acquire_lock(self, user_id: str, credentials_id: str, *args: str) -> RedisLock: + async def _acquire_lock( + self, user_id: str, credentials_id: str, *args: str + ) -> AsyncRedisLock: key = ( f"user:{user_id}", f"credentials:{credentials_id}", *args, ) - return self._locks.acquire(key) + locks = await self.locks() + return await locks.acquire(key) - @contextmanager - def _locked(self, user_id: str, credentials_id: str, *args: str): - lock = self._acquire_lock(user_id, credentials_id, *args) + @asynccontextmanager + async def _locked(self, user_id: str, credentials_id: str, *args: str): + lock = await self._acquire_lock(user_id, credentials_id, *args) try: yield finally: - lock.release() + if (await lock.locked()) and (await lock.owned()): + await lock.release() - def release_all_locks(self): + async def release_all_locks(self): """Call this on process termination to ensure all locks are released""" - self._locks.release_all_locks() - self.store.locks.release_all_locks() + await (await self.locks()).release_all_locks() + await (await self.store.locks()).release_all_locks() -def _get_provider_oauth_handler(provider_name: str) -> "BaseOAuthHandler": +async def _get_provider_oauth_handler(provider_name_str: str) -> "BaseOAuthHandler": + provider_name = ProviderName(provider_name_str) if provider_name not in HANDLERS_BY_NAME: raise KeyError(f"Unknown provider '{provider_name}'") - client_id = getattr(settings.secrets, f"{provider_name}_client_id") - client_secret = getattr(settings.secrets, f"{provider_name}_client_secret") + provider_creds = CREDENTIALS_BY_PROVIDER[provider_name] + if not provider_creds.use_secrets: + # This is safe to do as we check that the env vars exist in the registry + client_id = ( + os.getenv(provider_creds.client_id_env_var) + if provider_creds.client_id_env_var + else None + ) + client_secret = ( + os.getenv(provider_creds.client_secret_env_var) + if provider_creds.client_secret_env_var + else None + ) + else: + client_id = getattr(settings.secrets, f"{provider_name.value}_client_id") + client_secret = getattr( + settings.secrets, f"{provider_name.value}_client_secret" + ) + if not (client_id and client_secret): raise MissingConfigError( f"Integration with provider '{provider_name}' is not configured", diff --git a/autogpt_platform/backend/backend/integrations/oauth/__init__.py b/autogpt_platform/backend/backend/integrations/oauth/__init__.py index f5888f07a83e..137b9eadfdd0 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/__init__.py +++ b/autogpt_platform/backend/backend/integrations/oauth/__init__.py @@ -1,22 +1,228 @@ -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Optional +from pydantic import BaseModel + +from backend.integrations.oauth.todoist import TodoistOAuthHandler + +from .discord import DiscordOAuthHandler from .github import GitHubOAuthHandler from .google import GoogleOAuthHandler from .notion import NotionOAuthHandler +from .twitter import TwitterOAuthHandler if TYPE_CHECKING: - from ..providers import ProviderName from .base import BaseOAuthHandler # --8<-- [start:HANDLERS_BY_NAMEExample] -HANDLERS_BY_NAME: dict["ProviderName", type["BaseOAuthHandler"]] = { - handler.PROVIDER_NAME: handler - for handler in [ - GitHubOAuthHandler, - GoogleOAuthHandler, - NotionOAuthHandler, - ] +# Build handlers dict with string keys for compatibility with SDK auto-registration +_ORIGINAL_HANDLERS = [ + DiscordOAuthHandler, + GitHubOAuthHandler, + GoogleOAuthHandler, + NotionOAuthHandler, + TwitterOAuthHandler, + TodoistOAuthHandler, +] + +# Start with original handlers +_handlers_dict = { + ( + handler.PROVIDER_NAME.value + if hasattr(handler.PROVIDER_NAME, "value") + else str(handler.PROVIDER_NAME) + ): handler + for handler in _ORIGINAL_HANDLERS } + + +class SDKAwareCredentials(BaseModel): + """OAuth credentials configuration.""" + + use_secrets: bool = True + client_id_env_var: Optional[str] = None + client_secret_env_var: Optional[str] = None + + +_credentials_by_provider = {} +# Add default credentials for original handlers +for handler in _ORIGINAL_HANDLERS: + provider_name = ( + handler.PROVIDER_NAME.value + if hasattr(handler.PROVIDER_NAME, "value") + else str(handler.PROVIDER_NAME) + ) + _credentials_by_provider[provider_name] = SDKAwareCredentials( + use_secrets=True, client_id_env_var=None, client_secret_env_var=None + ) + + +# Create a custom dict class that includes SDK handlers +class SDKAwareHandlersDict(dict): + """Dictionary that automatically includes SDK-registered OAuth handlers.""" + + def __getitem__(self, key): + # First try the original handlers + if key in _handlers_dict: + return _handlers_dict[key] + + # Then try SDK handlers + try: + from backend.sdk import AutoRegistry + + sdk_handlers = AutoRegistry.get_oauth_handlers() + if key in sdk_handlers: + return sdk_handlers[key] + except ImportError: + pass + + # If not found, raise KeyError + raise KeyError(key) + + def get(self, key, default=None): + try: + return self[key] + except KeyError: + return default + + def __contains__(self, key): + if key in _handlers_dict: + return True + try: + from backend.sdk import AutoRegistry + + sdk_handlers = AutoRegistry.get_oauth_handlers() + return key in sdk_handlers + except ImportError: + return False + + def keys(self): + # Combine all keys into a single dict and return its keys view + combined = dict(_handlers_dict) + try: + from backend.sdk import AutoRegistry + + sdk_handlers = AutoRegistry.get_oauth_handlers() + combined.update(sdk_handlers) + except ImportError: + pass + return combined.keys() + + def values(self): + combined = dict(_handlers_dict) + try: + from backend.sdk import AutoRegistry + + sdk_handlers = AutoRegistry.get_oauth_handlers() + combined.update(sdk_handlers) + except ImportError: + pass + return combined.values() + + def items(self): + combined = dict(_handlers_dict) + try: + from backend.sdk import AutoRegistry + + sdk_handlers = AutoRegistry.get_oauth_handlers() + combined.update(sdk_handlers) + except ImportError: + pass + return combined.items() + + +class SDKAwareCredentialsDict(dict): + """Dictionary that automatically includes SDK-registered OAuth credentials.""" + + def __getitem__(self, key): + # First try the original handlers + if key in _credentials_by_provider: + return _credentials_by_provider[key] + + # Then try SDK credentials + try: + from backend.sdk import AutoRegistry + + sdk_credentials = AutoRegistry.get_oauth_credentials() + if key in sdk_credentials: + # Convert from SDKOAuthCredentials to SDKAwareCredentials + sdk_cred = sdk_credentials[key] + return SDKAwareCredentials( + use_secrets=sdk_cred.use_secrets, + client_id_env_var=sdk_cred.client_id_env_var, + client_secret_env_var=sdk_cred.client_secret_env_var, + ) + except ImportError: + pass + + # If not found, raise KeyError + raise KeyError(key) + + def get(self, key, default=None): + try: + return self[key] + except KeyError: + return default + + def __contains__(self, key): + if key in _credentials_by_provider: + return True + try: + from backend.sdk import AutoRegistry + + sdk_credentials = AutoRegistry.get_oauth_credentials() + return key in sdk_credentials + except ImportError: + return False + + def keys(self): + # Combine all keys into a single dict and return its keys view + combined = dict(_credentials_by_provider) + try: + from backend.sdk import AutoRegistry + + sdk_credentials = AutoRegistry.get_oauth_credentials() + combined.update(sdk_credentials) + except ImportError: + pass + return combined.keys() + + def values(self): + combined = dict(_credentials_by_provider) + try: + from backend.sdk import AutoRegistry + + sdk_credentials = AutoRegistry.get_oauth_credentials() + # Convert SDK credentials to SDKAwareCredentials + for key, sdk_cred in sdk_credentials.items(): + combined[key] = SDKAwareCredentials( + use_secrets=sdk_cred.use_secrets, + client_id_env_var=sdk_cred.client_id_env_var, + client_secret_env_var=sdk_cred.client_secret_env_var, + ) + except ImportError: + pass + return combined.values() + + def items(self): + combined = dict(_credentials_by_provider) + try: + from backend.sdk import AutoRegistry + + sdk_credentials = AutoRegistry.get_oauth_credentials() + # Convert SDK credentials to SDKAwareCredentials + for key, sdk_cred in sdk_credentials.items(): + combined[key] = SDKAwareCredentials( + use_secrets=sdk_cred.use_secrets, + client_id_env_var=sdk_cred.client_id_env_var, + client_secret_env_var=sdk_cred.client_secret_env_var, + ) + except ImportError: + pass + return combined.items() + + +HANDLERS_BY_NAME: dict[str, type["BaseOAuthHandler"]] = SDKAwareHandlersDict() +CREDENTIALS_BY_PROVIDER: dict[str, SDKAwareCredentials] = SDKAwareCredentialsDict() # --8<-- [end:HANDLERS_BY_NAMEExample] __all__ = ["HANDLERS_BY_NAME"] diff --git a/autogpt_platform/backend/backend/integrations/oauth/base.py b/autogpt_platform/backend/backend/integrations/oauth/base.py index 54786ba1aa4c..f8e1babe2609 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/base.py +++ b/autogpt_platform/backend/backend/integrations/oauth/base.py @@ -1,7 +1,7 @@ import logging import time from abc import ABC, abstractmethod -from typing import ClassVar +from typing import ClassVar, Optional from backend.data.model import OAuth2Credentials from backend.integrations.providers import ProviderName @@ -11,7 +11,7 @@ class BaseOAuthHandler(ABC): # --8<-- [start:BaseOAuthHandler1] - PROVIDER_NAME: ClassVar[ProviderName] + PROVIDER_NAME: ClassVar[ProviderName | str] DEFAULT_SCOPES: ClassVar[list[str]] = [] # --8<-- [end:BaseOAuthHandler1] @@ -23,15 +23,17 @@ def __init__(self, client_id: str, client_secret: str, redirect_uri: str): ... @abstractmethod # --8<-- [start:BaseOAuthHandler3] - def get_login_url(self, scopes: list[str], state: str) -> str: + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: # --8<-- [end:BaseOAuthHandler3] """Constructs a login URL that the user can be redirected to""" ... @abstractmethod # --8<-- [start:BaseOAuthHandler4] - def exchange_code_for_tokens( - self, code: str, scopes: list[str] + async def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] ) -> OAuth2Credentials: # --8<-- [end:BaseOAuthHandler4] """Exchanges the acquired authorization code from login for a set of tokens""" @@ -39,31 +41,33 @@ def exchange_code_for_tokens( @abstractmethod # --8<-- [start:BaseOAuthHandler5] - def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials: + async def _refresh_tokens( + self, credentials: OAuth2Credentials + ) -> OAuth2Credentials: # --8<-- [end:BaseOAuthHandler5] """Implements the token refresh mechanism""" ... @abstractmethod # --8<-- [start:BaseOAuthHandler6] - def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: # --8<-- [end:BaseOAuthHandler6] """Revokes the given token at provider, returns False provider does not support it""" ... - def refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials: + async def refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials: if credentials.provider != self.PROVIDER_NAME: raise ValueError( f"{self.__class__.__name__} can not refresh tokens " f"for other provider '{credentials.provider}'" ) - return self._refresh_tokens(credentials) + return await self._refresh_tokens(credentials) - def get_access_token(self, credentials: OAuth2Credentials) -> str: + async def get_access_token(self, credentials: OAuth2Credentials) -> str: """Returns a valid access token, refreshing it first if needed""" if self.needs_refresh(credentials): - credentials = self.refresh_tokens(credentials) + credentials = await self.refresh_tokens(credentials) return credentials.access_token.get_secret_value() def needs_refresh(self, credentials: OAuth2Credentials) -> bool: @@ -77,8 +81,6 @@ def handle_default_scopes(self, scopes: list[str]) -> list[str]: """Handles the default scopes for the provider""" # If scopes are empty, use the default scopes for the provider if not scopes: - logger.debug( - f"Using default scopes for provider {self.PROVIDER_NAME.value}" - ) + logger.debug(f"Using default scopes for provider {str(self.PROVIDER_NAME)}") scopes = self.DEFAULT_SCOPES return scopes diff --git a/autogpt_platform/backend/backend/integrations/oauth/discord.py b/autogpt_platform/backend/backend/integrations/oauth/discord.py new file mode 100644 index 000000000000..1d70abc3720f --- /dev/null +++ b/autogpt_platform/backend/backend/integrations/oauth/discord.py @@ -0,0 +1,175 @@ +import time +from typing import Optional +from urllib.parse import urlencode + +from backend.data.model import OAuth2Credentials +from backend.integrations.providers import ProviderName +from backend.util.request import Requests + +from .base import BaseOAuthHandler + + +class DiscordOAuthHandler(BaseOAuthHandler): + """ + Discord OAuth2 handler implementation. + + Based on the documentation at: + - https://discord.com/developers/docs/topics/oauth2 + + Discord OAuth2 tokens expire after 7 days by default and include refresh tokens. + """ + + PROVIDER_NAME = ProviderName.DISCORD + DEFAULT_SCOPES = ["identify"] # Basic user information + + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + self.auth_base_url = "https://discord.com/oauth2/authorize" + self.token_url = "https://discord.com/api/oauth2/token" + self.revoke_url = "https://discord.com/api/oauth2/token/revoke" + + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: + # Handle default scopes + scopes = self.handle_default_scopes(scopes) + + params = { + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "response_type": "code", + "scope": " ".join(scopes), + "state": state, + } + + # Discord supports PKCE + if code_challenge: + params["code_challenge"] = code_challenge + params["code_challenge_method"] = "S256" + + return f"{self.auth_base_url}?{urlencode(params)}" + + async def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] + ) -> OAuth2Credentials: + params = { + "code": code, + "redirect_uri": self.redirect_uri, + "grant_type": "authorization_code", + } + + # Include PKCE verifier if provided + if code_verifier: + params["code_verifier"] = code_verifier + + return await self._request_tokens(params) + + async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + if not credentials.access_token: + raise ValueError("No access token to revoke") + + # Discord requires client authentication for token revocation + data = { + "token": credentials.access_token.get_secret_value(), + "token_type_hint": "access_token", + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + + response = await Requests().post( + url=self.revoke_url, + data=data, + headers=headers, + auth=(self.client_id, self.client_secret), + ) + + # Discord returns 200 OK for successful revocation + return response.status == 200 + + async def _refresh_tokens( + self, credentials: OAuth2Credentials + ) -> OAuth2Credentials: + if not credentials.refresh_token: + return credentials + + return await self._request_tokens( + { + "refresh_token": credentials.refresh_token.get_secret_value(), + "grant_type": "refresh_token", + }, + current_credentials=credentials, + ) + + async def _request_tokens( + self, + params: dict[str, str], + current_credentials: Optional[OAuth2Credentials] = None, + ) -> OAuth2Credentials: + request_body = { + "client_id": self.client_id, + "client_secret": self.client_secret, + **params, + } + + headers = { + "Content-Type": "application/x-www-form-urlencoded", + } + + response = await Requests().post( + self.token_url, data=request_body, headers=headers + ) + token_data: dict = response.json() + + # Get username if this is a new token request + username = None + if "access_token" in token_data: + username = await self._request_username(token_data["access_token"]) + + now = int(time.time()) + new_credentials = OAuth2Credentials( + provider=self.PROVIDER_NAME, + title=current_credentials.title if current_credentials else None, + username=username, + access_token=token_data["access_token"], + scopes=token_data.get("scope", "").split() + or (current_credentials.scopes if current_credentials else []), + refresh_token=token_data.get("refresh_token"), + # Discord tokens expire after expires_in seconds (typically 7 days) + access_token_expires_at=( + now + expires_in + if (expires_in := token_data.get("expires_in", None)) + else None + ), + # Discord doesn't provide separate refresh token expiration + refresh_token_expires_at=None, + ) + + if current_credentials: + new_credentials.id = current_credentials.id + + return new_credentials + + async def _request_username(self, access_token: str) -> str | None: + """ + Fetch the username using the Discord OAuth2 @me endpoint. + """ + url = "https://discord.com/api/oauth2/@me" + headers = { + "Authorization": f"Bearer {access_token}", + } + + response = await Requests().get(url, headers=headers) + + if not response.ok: + return None + + # Get user info from the response + data = response.json() + user_info = data.get("user", {}) + + # Return username (without discriminator) + return user_info.get("username") diff --git a/autogpt_platform/backend/backend/integrations/oauth/github.py b/autogpt_platform/backend/backend/integrations/oauth/github.py index d83c9b2093a0..ebec1166607c 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/github.py +++ b/autogpt_platform/backend/backend/integrations/oauth/github.py @@ -4,7 +4,7 @@ from backend.data.model import OAuth2Credentials from backend.integrations.providers import ProviderName -from backend.util.request import requests +from backend.util.request import Requests from .base import BaseOAuthHandler @@ -34,7 +34,9 @@ def __init__(self, client_id: str, client_secret: str, redirect_uri: str): self.token_url = "https://github.com/login/oauth/access_token" self.revoke_url = "https://api.github.com/applications/{client_id}/token" - def get_login_url(self, scopes: list[str], state: str) -> str: + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: params = { "client_id": self.client_id, "redirect_uri": self.redirect_uri, @@ -43,12 +45,14 @@ def get_login_url(self, scopes: list[str], state: str) -> str: } return f"{self.auth_base_url}?{urlencode(params)}" - def exchange_code_for_tokens( - self, code: str, scopes: list[str] + async def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] ) -> OAuth2Credentials: - return self._request_tokens({"code": code, "redirect_uri": self.redirect_uri}) + return await self._request_tokens( + {"code": code, "redirect_uri": self.redirect_uri} + ) - def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: if not credentials.access_token: raise ValueError("No access token to revoke") @@ -57,7 +61,7 @@ def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: "X-GitHub-Api-Version": "2022-11-28", } - requests.delete( + await Requests().delete( url=self.revoke_url.format(client_id=self.client_id), auth=(self.client_id, self.client_secret), headers=headers, @@ -65,18 +69,20 @@ def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: ) return True - def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials: + async def _refresh_tokens( + self, credentials: OAuth2Credentials + ) -> OAuth2Credentials: if not credentials.refresh_token: return credentials - return self._request_tokens( + return await self._request_tokens( { "refresh_token": credentials.refresh_token.get_secret_value(), "grant_type": "refresh_token", } ) - def _request_tokens( + async def _request_tokens( self, params: dict[str, str], current_credentials: Optional[OAuth2Credentials] = None, @@ -87,10 +93,12 @@ def _request_tokens( **params, } headers = {"Accept": "application/json"} - response = requests.post(self.token_url, data=request_body, headers=headers) + response = await Requests().post( + self.token_url, data=request_body, headers=headers + ) token_data: dict = response.json() - username = self._request_username(token_data["access_token"]) + username = await self._request_username(token_data["access_token"]) now = int(time.time()) new_credentials = OAuth2Credentials( @@ -122,7 +130,7 @@ def _request_tokens( new_credentials.id = current_credentials.id return new_credentials - def _request_username(self, access_token: str) -> str | None: + async def _request_username(self, access_token: str) -> str | None: url = "https://api.github.com/user" headers = { "Accept": "application/vnd.github+json", @@ -130,13 +138,14 @@ def _request_username(self, access_token: str) -> str | None: "X-GitHub-Api-Version": "2022-11-28", } - response = requests.get(url, headers=headers) + response = await Requests().get(url, headers=headers) if not response.ok: return None # Get the login (username) - return response.json().get("login") + resp = response.json() + return resp.get("login") # --8<-- [end:GithubOAuthHandlerExample] diff --git a/autogpt_platform/backend/backend/integrations/oauth/google.py b/autogpt_platform/backend/backend/integrations/oauth/google.py index 5a03e615a4c7..bba2bc71c5a4 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/google.py +++ b/autogpt_platform/backend/backend/integrations/oauth/google.py @@ -1,4 +1,5 @@ import logging +from typing import Optional from google.auth.external_account_authorized_user import ( Credentials as ExternalAccountCredentials, @@ -38,7 +39,9 @@ def __init__(self, client_id: str, client_secret: str, redirect_uri: str): self.token_uri = "https://oauth2.googleapis.com/token" self.revoke_uri = "https://oauth2.googleapis.com/revoke" - def get_login_url(self, scopes: list[str], state: str) -> str: + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: all_scopes = list(set(scopes + self.DEFAULT_SCOPES)) logger.debug(f"Setting up OAuth flow with scopes: {all_scopes}") flow = self._setup_oauth_flow(all_scopes) @@ -51,8 +54,8 @@ def get_login_url(self, scopes: list[str], state: str) -> str: ) return authorization_url - def exchange_code_for_tokens( - self, code: str, scopes: list[str] + async def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] ) -> OAuth2Credentials: logger.debug(f"Exchanging code for tokens with scopes: {scopes}") @@ -73,7 +76,7 @@ def exchange_code_for_tokens( logger.debug(f"Scopes granted by Google: {granted_scopes}") google_creds = flow.credentials - logger.debug(f"Received credentials: {google_creds}") + logger.debug("Received credentials") logger.debug("Requesting user email") username = self._request_email(google_creds) @@ -103,7 +106,7 @@ def exchange_code_for_tokens( return credentials - def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: session = AuthorizedSession(credentials) session.post( self.revoke_uri, @@ -124,7 +127,9 @@ def _request_email( return None return response.json()["email"] - def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials: + async def _refresh_tokens( + self, credentials: OAuth2Credentials + ) -> OAuth2Credentials: # Google credentials should ALWAYS have a refresh token assert credentials.refresh_token diff --git a/autogpt_platform/backend/backend/integrations/oauth/notion.py b/autogpt_platform/backend/backend/integrations/oauth/notion.py index e71bae29560c..67fc5bcc2539 100644 --- a/autogpt_platform/backend/backend/integrations/oauth/notion.py +++ b/autogpt_platform/backend/backend/integrations/oauth/notion.py @@ -1,9 +1,10 @@ from base64 import b64encode +from typing import Optional from urllib.parse import urlencode from backend.data.model import OAuth2Credentials from backend.integrations.providers import ProviderName -from backend.util.request import requests +from backend.util.request import Requests from .base import BaseOAuthHandler @@ -26,7 +27,9 @@ def __init__(self, client_id: str, client_secret: str, redirect_uri: str): self.auth_base_url = "https://api.notion.com/v1/oauth/authorize" self.token_url = "https://api.notion.com/v1/oauth/token" - def get_login_url(self, scopes: list[str], state: str) -> str: + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: params = { "client_id": self.client_id, "redirect_uri": self.redirect_uri, @@ -36,8 +39,8 @@ def get_login_url(self, scopes: list[str], state: str) -> str: } return f"{self.auth_base_url}?{urlencode(params)}" - def exchange_code_for_tokens( - self, code: str, scopes: list[str] + async def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] ) -> OAuth2Credentials: request_body = { "grant_type": "authorization_code", @@ -49,7 +52,9 @@ def exchange_code_for_tokens( "Authorization": f"Basic {auth_str}", "Accept": "application/json", } - response = requests.post(self.token_url, json=request_body, headers=headers) + response = await Requests().post( + self.token_url, json=request_body, headers=headers + ) token_data = response.json() # Email is only available for non-bot users email = ( @@ -77,11 +82,13 @@ def exchange_code_for_tokens( }, ) - def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: # Notion doesn't support token revocation return False - def _refresh_tokens(self, credentials: OAuth2Credentials) -> OAuth2Credentials: + async def _refresh_tokens( + self, credentials: OAuth2Credentials + ) -> OAuth2Credentials: # Notion doesn't support token refresh return credentials diff --git a/autogpt_platform/backend/backend/integrations/oauth/todoist.py b/autogpt_platform/backend/backend/integrations/oauth/todoist.py new file mode 100644 index 000000000000..66d34f95e8dd --- /dev/null +++ b/autogpt_platform/backend/backend/integrations/oauth/todoist.py @@ -0,0 +1,79 @@ +import urllib.parse +from typing import ClassVar, Optional + +from backend.data.model import OAuth2Credentials, ProviderName +from backend.integrations.oauth.base import BaseOAuthHandler +from backend.util.request import Requests + + +class TodoistOAuthHandler(BaseOAuthHandler): + PROVIDER_NAME = ProviderName.TODOIST + DEFAULT_SCOPES: ClassVar[list[str]] = [ + "task:add", + "data:read", + "data:read_write", + "data:delete", + "project:delete", + ] + + AUTHORIZE_URL = "https://todoist.com/oauth/authorize" + TOKEN_URL = "https://todoist.com/oauth/access_token" + + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: + params = { + "client_id": self.client_id, + "scope": ",".join(self.DEFAULT_SCOPES), + "state": state, + } + + return f"{self.AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" + + async def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] + ) -> OAuth2Credentials: + """Exchange authorization code for access tokens""" + + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "redirect_uri": self.redirect_uri, + } + + response = await Requests().post(self.TOKEN_URL, data=data) + tokens = response.json() + + response = await Requests().post( + "https://api.todoist.com/sync/v9/sync", + headers={"Authorization": f"Bearer {tokens['access_token']}"}, + data={"sync_token": "*", "resource_types": '["user"]'}, + ) + user_info = response.json() + user_email = user_info["user"].get("email") + + return OAuth2Credentials( + provider=self.PROVIDER_NAME, + title=None, + username=user_email, + access_token=tokens["access_token"], + refresh_token=None, + access_token_expires_at=None, + refresh_token_expires_at=None, + scopes=scopes, + ) + + async def _refresh_tokens( + self, credentials: OAuth2Credentials + ) -> OAuth2Credentials: + # Todoist does not support token refresh + return credentials + + async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + return False diff --git a/autogpt_platform/backend/backend/integrations/oauth/twitter.py b/autogpt_platform/backend/backend/integrations/oauth/twitter.py new file mode 100644 index 000000000000..486c14a21084 --- /dev/null +++ b/autogpt_platform/backend/backend/integrations/oauth/twitter.py @@ -0,0 +1,173 @@ +import time +import urllib.parse +from typing import ClassVar, Optional + +from backend.data.model import OAuth2Credentials, ProviderName +from backend.integrations.oauth.base import BaseOAuthHandler +from backend.util.request import Requests + + +class TwitterOAuthHandler(BaseOAuthHandler): + PROVIDER_NAME = ProviderName.TWITTER + DEFAULT_SCOPES: ClassVar[list[str]] = [ + "tweet.read", + "tweet.write", + "tweet.moderate.write", + "users.read", + "follows.read", + "follows.write", + "offline.access", + "space.read", + "mute.read", + "mute.write", + "like.read", + "like.write", + "list.read", + "list.write", + "block.read", + "block.write", + "bookmark.read", + "bookmark.write", + ] + + AUTHORIZE_URL = "https://twitter.com/i/oauth2/authorize" + TOKEN_URL = "https://api.x.com/2/oauth2/token" + USERNAME_URL = "https://api.x.com/2/users/me" + REVOKE_URL = "https://api.x.com/2/oauth2/revoke" + + def __init__(self, client_id: str, client_secret: str, redirect_uri: str): + self.client_id = client_id + self.client_secret = client_secret + self.redirect_uri = redirect_uri + + def get_login_url( + self, scopes: list[str], state: str, code_challenge: Optional[str] + ) -> str: + """Generate Twitter OAuth 2.0 authorization URL""" + # scopes = self.handle_default_scopes(scopes) + + if code_challenge is None: + raise ValueError("code_challenge is required for Twitter OAuth") + + params = { + "response_type": "code", + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "scope": " ".join(self.DEFAULT_SCOPES), + "state": state, + "code_challenge": code_challenge, + "code_challenge_method": "S256", + } + + return f"{self.AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" + + async def exchange_code_for_tokens( + self, code: str, scopes: list[str], code_verifier: Optional[str] + ) -> OAuth2Credentials: + """Exchange authorization code for access tokens""" + + headers = {"Content-Type": "application/x-www-form-urlencoded"} + + data = { + "code": code, + "grant_type": "authorization_code", + "redirect_uri": self.redirect_uri, + "code_verifier": code_verifier, + } + + auth = (self.client_id, self.client_secret) + + response = await Requests().post( + self.TOKEN_URL, headers=headers, data=data, auth=auth + ) + tokens = response.json() + + username = await self._get_username(tokens["access_token"]) + + return OAuth2Credentials( + provider=self.PROVIDER_NAME, + title=None, + username=username, + access_token=tokens["access_token"], + refresh_token=tokens.get("refresh_token"), + access_token_expires_at=int(time.time()) + tokens["expires_in"], + refresh_token_expires_at=None, + scopes=scopes, + ) + + async def _get_username(self, access_token: str) -> str: + """Get the username from the access token""" + headers = {"Authorization": f"Bearer {access_token}"} + + params = {"user.fields": "username"} + + response = await Requests().get( + f"{self.USERNAME_URL}?{urllib.parse.urlencode(params)}", headers=headers + ) + + return response.json()["data"]["username"] + + async def _refresh_tokens( + self, credentials: OAuth2Credentials + ) -> OAuth2Credentials: + """Refresh access tokens using refresh token""" + if not credentials.refresh_token: + raise ValueError("No refresh token available") + + header = {"Content-Type": "application/x-www-form-urlencoded"} + data = { + "grant_type": "refresh_token", + "refresh_token": credentials.refresh_token.get_secret_value(), + } + + auth = (self.client_id, self.client_secret) + + response = await Requests().post( + self.TOKEN_URL, headers=header, data=data, auth=auth + ) + + if not response.ok: + error_text = response.text + print("HTTP Error:", response.status) + print("Response Content:", error_text) + raise ValueError(f"HTTP Error: {response.status} - {error_text}") + + tokens = response.json() + + username = await self._get_username(tokens["access_token"]) + + return OAuth2Credentials( + id=credentials.id, + provider=self.PROVIDER_NAME, + title=None, + username=username, + access_token=tokens["access_token"], + refresh_token=tokens["refresh_token"], + access_token_expires_at=int(time.time()) + tokens["expires_in"], + scopes=credentials.scopes, + refresh_token_expires_at=None, + ) + + async def revoke_tokens(self, credentials: OAuth2Credentials) -> bool: + """Revoke the access token""" + + header = {"Content-Type": "application/x-www-form-urlencoded"} + + data = { + "token": credentials.access_token.get_secret_value(), + "token_type_hint": "access_token", + } + + auth = (self.client_id, self.client_secret) + + response = await Requests().post( + self.REVOKE_URL, headers=header, data=data, auth=auth + ) + + if not response.ok: + error_text = response.text + print("HTTP Error:", response.status) + print("Response Content:", error_text) + raise ValueError(f"HTTP Error: {response.status} - {error_text}") + + return response.ok diff --git a/autogpt_platform/backend/backend/integrations/providers.py b/autogpt_platform/backend/backend/integrations/providers.py index b9b99edf8ed3..3564ad32a872 100644 --- a/autogpt_platform/backend/backend/integrations/providers.py +++ b/autogpt_platform/backend/backend/integrations/providers.py @@ -1,31 +1,106 @@ from enum import Enum +from typing import Any # --8<-- [start:ProviderName] class ProviderName(str, Enum): + """ + Provider names for integrations. + + This enum extends str to accept any string value while maintaining + backward compatibility with existing provider constants. + """ + + AIML_API = "aiml_api" ANTHROPIC = "anthropic" + APOLLO = "apollo" COMPASS = "compass" DISCORD = "discord" D_ID = "d_id" E2B = "e2b" - EXA = "exa" FAL = "fal" GITHUB = "github" GOOGLE = "google" GOOGLE_MAPS = "google_maps" GROQ = "groq" + HTTP = "http" HUBSPOT = "hubspot" + ENRICHLAYER = "enrichlayer" IDEOGRAM = "ideogram" JINA = "jina" + LLAMA_API = "llama_api" MEDIUM = "medium" + MEM0 = "mem0" NOTION = "notion" + NVIDIA = "nvidia" OLLAMA = "ollama" OPENAI = "openai" OPENWEATHERMAP = "openweathermap" OPEN_ROUTER = "open_router" PINECONE = "pinecone" + REDDIT = "reddit" REPLICATE = "replicate" REVID = "revid" + SCREENSHOTONE = "screenshotone" SLANT3D = "slant3d" + SMARTLEAD = "smartlead" + SMTP = "smtp" + TWITTER = "twitter" + TODOIST = "todoist" UNREAL_SPEECH = "unreal_speech" + V0 = "v0" + ZEROBOUNCE = "zerobounce" + + @classmethod + def _missing_(cls, value: Any) -> "ProviderName": + """ + Allow any string value to be used as a ProviderName. + This enables SDK users to define custom providers without + modifying the enum. + """ + if isinstance(value, str): + # Create a pseudo-member that behaves like an enum member + pseudo_member = str.__new__(cls, value) + pseudo_member._name_ = value.upper() + pseudo_member._value_ = value + return pseudo_member + return None # type: ignore + + @classmethod + def __get_pydantic_json_schema__(cls, schema, handler): + """ + Custom JSON schema generation that allows any string value, + not just the predefined enum values. + """ + # Get the default schema + json_schema = handler(schema) + + # Remove the enum constraint to allow any string + if "enum" in json_schema: + del json_schema["enum"] + + # Keep the type as string + json_schema["type"] = "string" + + # Update description to indicate custom providers are allowed + json_schema["description"] = ( + "Provider name for integrations. " + "Can be any string value, including custom provider names." + ) + + return json_schema + + @classmethod + def __get_pydantic_core_schema__(cls, source_type, handler): + """ + Pydantic v2 core schema that allows any string value. + """ + from pydantic_core import core_schema + + # Create a string schema that validates any string + return core_schema.no_info_after_validator_function( + cls, + core_schema.str_schema(), + ) + # --8<-- [end:ProviderName] diff --git a/autogpt_platform/backend/backend/integrations/webhooks/__init__.py b/autogpt_platform/backend/backend/integrations/webhooks/__init__.py index 4ff4f8b5e0c5..3cf1dd72cfa8 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/__init__.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/__init__.py @@ -1,22 +1,42 @@ +import functools from typing import TYPE_CHECKING -from .compass import CompassWebhookManager -from .github import GithubWebhooksManager -from .slant3d import Slant3DWebhooksManager - if TYPE_CHECKING: from ..providers import ProviderName from ._base import BaseWebhooksManager -# --8<-- [start:WEBHOOK_MANAGERS_BY_NAME] -WEBHOOK_MANAGERS_BY_NAME: dict["ProviderName", type["BaseWebhooksManager"]] = { - handler.PROVIDER_NAME: handler - for handler in [ - CompassWebhookManager, - GithubWebhooksManager, - Slant3DWebhooksManager, - ] -} -# --8<-- [end:WEBHOOK_MANAGERS_BY_NAME] - -__all__ = ["WEBHOOK_MANAGERS_BY_NAME"] + +# --8<-- [start:load_webhook_managers] +@functools.cache +def load_webhook_managers() -> dict["ProviderName", type["BaseWebhooksManager"]]: + webhook_managers = {} + + from .compass import CompassWebhookManager + from .github import GithubWebhooksManager + from .slant3d import Slant3DWebhooksManager + + webhook_managers.update( + { + handler.PROVIDER_NAME: handler + for handler in [ + CompassWebhookManager, + GithubWebhooksManager, + Slant3DWebhooksManager, + ] + } + ) + return webhook_managers + + +# --8<-- [end:load_webhook_managers] + + +def get_webhook_manager(provider_name: "ProviderName") -> "BaseWebhooksManager": + return load_webhook_managers()[provider_name]() + + +def supports_webhooks(provider_name: "ProviderName") -> bool: + return provider_name in load_webhook_managers() + + +__all__ = ["get_webhook_manager", "supports_webhooks"] diff --git a/autogpt_platform/backend/backend/integrations/webhooks/_base.py b/autogpt_platform/backend/backend/integrations/webhooks/_base.py index 4d6066d40d20..9342a6417bda 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/_base.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/_base.py @@ -7,13 +7,14 @@ from fastapi import Request from strenum import StrEnum -from backend.data import integrations +import backend.data.integrations as integrations from backend.data.model import Credentials from backend.integrations.providers import ProviderName -from backend.integrations.webhooks.utils import webhook_ingress_url from backend.util.exceptions import MissingConfigError from backend.util.settings import Config +from .utils import webhook_ingress_url + logger = logging.getLogger(__name__) app_config = Config() @@ -41,51 +42,84 @@ async def get_suitable_auto_webhook( ) if webhook := await integrations.find_webhook_by_credentials_and_props( - credentials.id, webhook_type, resource, events + user_id=user_id, + credentials_id=credentials.id, + webhook_type=webhook_type, + resource=resource, + events=events, ): return webhook + return await self._create_webhook( - user_id, webhook_type, events, resource, credentials + user_id=user_id, + webhook_type=webhook_type, + events=events, + resource=resource, + credentials=credentials, ) async def get_manual_webhook( self, user_id: str, - graph_id: str, webhook_type: WT, events: list[str], - ): - if current_webhook := await integrations.find_webhook_by_graph_and_props( - graph_id, self.PROVIDER_NAME, webhook_type, events + graph_id: Optional[str] = None, + preset_id: Optional[str] = None, + ) -> integrations.Webhook: + """ + Tries to find an existing webhook tied to `graph_id`/`preset_id`, + or creates a new webhook if none exists. + + Existing webhooks are matched by `user_id`, `webhook_type`, + and `graph_id`/`preset_id`. + + If an existing webhook is found, we check if the events match and update them + if necessary. We do this rather than creating a new webhook + to avoid changing the webhook URL for existing manual webhooks. + """ + if (graph_id or preset_id) and ( + current_webhook := await integrations.find_webhook_by_graph_and_props( + user_id=user_id, + provider=self.PROVIDER_NAME.value, + webhook_type=webhook_type, + graph_id=graph_id, + preset_id=preset_id, + ) ): + if set(current_webhook.events) != set(events): + current_webhook = await integrations.update_webhook( + current_webhook.id, events=events + ) return current_webhook + return await self._create_webhook( - user_id, - webhook_type, - events, + user_id=user_id, + webhook_type=webhook_type, + events=events, register=False, ) async def prune_webhook_if_dangling( - self, webhook_id: str, credentials: Optional[Credentials] + self, user_id: str, webhook_id: str, credentials: Optional[Credentials] ) -> bool: - webhook = await integrations.get_webhook(webhook_id) - if webhook.attached_nodes is None: - raise ValueError("Error retrieving webhook including attached nodes") - if webhook.attached_nodes: + webhook = await integrations.get_webhook(webhook_id, include_relations=True) + if webhook.triggered_nodes or webhook.triggered_presets: # Don't prune webhook if in use return False if credentials: await self._deregister_webhook(webhook, credentials) - await integrations.delete_webhook(webhook.id) + await integrations.delete_webhook(user_id, webhook.id) return True # --8<-- [start:BaseWebhooksManager3] @classmethod @abstractmethod async def validate_payload( - cls, webhook: integrations.Webhook, request: Request + cls, + webhook: integrations.Webhook, + request: Request, + credentials: Credentials | None, ) -> tuple[dict, str]: """ Validates an incoming webhook request and returns its payload and type. @@ -168,7 +202,7 @@ async def _create_webhook( id = str(uuid4()) secret = secrets.token_hex(32) - provider_name = self.PROVIDER_NAME + provider_name: ProviderName = self.PROVIDER_NAME ingress_url = webhook_ingress_url(provider_name=provider_name, webhook_id=id) if register: if not credentials: diff --git a/autogpt_platform/backend/backend/integrations/webhooks/_manual_base.py b/autogpt_platform/backend/backend/integrations/webhooks/_manual_base.py index 0e1cc0dc4d95..cf749a3cf9b5 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/_manual_base.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/_manual_base.py @@ -1,7 +1,7 @@ import logging from backend.data import integrations -from backend.data.model import APIKeyCredentials, Credentials, OAuth2Credentials +from backend.data.model import Credentials from ._base import WT, BaseWebhooksManager @@ -25,6 +25,6 @@ async def _register_webhook( async def _deregister_webhook( self, webhook: integrations.Webhook, - credentials: OAuth2Credentials | APIKeyCredentials, + credentials: Credentials, ) -> None: pass diff --git a/autogpt_platform/backend/backend/integrations/webhooks/compass.py b/autogpt_platform/backend/backend/integrations/webhooks/compass.py index 8a2076a1dab1..6dbfc85604f3 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/compass.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/compass.py @@ -5,6 +5,7 @@ from backend.data import integrations from backend.integrations.providers import ProviderName +from backend.sdk import Credentials from ._manual_base import ManualWebhookManagerBase @@ -22,7 +23,10 @@ class CompassWebhookManager(ManualWebhookManagerBase): @classmethod async def validate_payload( - cls, webhook: integrations.Webhook, request: Request + cls, + webhook: integrations.Webhook, + request: Request, + credentials: Credentials | None, ) -> tuple[dict, str]: payload = await request.json() event_type = CompassWebhookType.TRANSCRIPTION # currently the only type diff --git a/autogpt_platform/backend/backend/integrations/webhooks/github.py b/autogpt_platform/backend/backend/integrations/webhooks/github.py index 8bf5639eb230..a4f74249f57b 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/github.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/github.py @@ -2,13 +2,13 @@ import hmac import logging -import requests from fastapi import HTTPException, Request from strenum import StrEnum from backend.data import integrations from backend.data.model import Credentials from backend.integrations.providers import ProviderName +from backend.util.request import Requests, Response from ._base import BaseWebhooksManager @@ -30,7 +30,10 @@ class GithubWebhooksManager(BaseWebhooksManager): @classmethod async def validate_payload( - cls, webhook: integrations.Webhook, request: Request + cls, + webhook: integrations.Webhook, + request: Request, + credentials: Credentials | None, ) -> tuple[dict, str]: if not (event_type := request.headers.get("X-GitHub-Event")): raise HTTPException( @@ -67,15 +70,15 @@ async def trigger_ping( headers = { **self.GITHUB_API_DEFAULT_HEADERS, - "Authorization": credentials.bearer(), + "Authorization": credentials.auth_header(), } repo, github_hook_id = webhook.resource, webhook.provider_webhook_id ping_url = f"{self.GITHUB_API_URL}/repos/{repo}/hooks/{github_hook_id}/pings" - response = requests.post(ping_url, headers=headers) + response = await Requests().post(ping_url, headers=headers) - if response.status_code != 204: + if response.status != 204: error_msg = extract_github_error_msg(response) raise ValueError(f"Failed to ping GitHub webhook: {error_msg}") @@ -96,7 +99,7 @@ async def _register_webhook( headers = { **self.GITHUB_API_DEFAULT_HEADERS, - "Authorization": credentials.bearer(), + "Authorization": credentials.auth_header(), } webhook_data = { "name": "web", @@ -110,13 +113,13 @@ async def _register_webhook( }, } - response = requests.post( + response = await Requests().post( f"{self.GITHUB_API_URL}/repos/{resource}/hooks", headers=headers, json=webhook_data, ) - if response.status_code != 201: + if response.status != 201: error_msg = extract_github_error_msg(response) if "not found" in error_msg.lower(): error_msg = ( @@ -126,8 +129,9 @@ async def _register_webhook( ) raise ValueError(f"Failed to create GitHub webhook: {error_msg}") - webhook_id = response.json()["id"] - config = response.json()["config"] + resp = response.json() + webhook_id = resp["id"] + config = resp["config"] return str(webhook_id), config @@ -142,7 +146,7 @@ async def _deregister_webhook( headers = { **self.GITHUB_API_DEFAULT_HEADERS, - "Authorization": credentials.bearer(), + "Authorization": credentials.auth_header(), } if webhook_type == self.WebhookType.REPO: @@ -153,9 +157,9 @@ async def _deregister_webhook( f"Unsupported webhook type '{webhook.webhook_type}'" ) - response = requests.delete(delete_url, headers=headers) + response = await Requests().delete(delete_url, headers=headers) - if response.status_code not in [204, 404]: + if response.status not in [204, 404]: # 204 means successful deletion, 404 means the webhook was already deleted error_msg = extract_github_error_msg(response) raise ValueError(f"Failed to delete GitHub webhook: {error_msg}") @@ -166,7 +170,7 @@ async def _deregister_webhook( # --8<-- [end:GithubWebhooksManager] -def extract_github_error_msg(response: requests.Response) -> str: +def extract_github_error_msg(response: Response) -> str: error_msgs = [] resp = response.json() if resp.get("message"): diff --git a/autogpt_platform/backend/backend/integrations/webhooks/graph_lifecycle_hooks.py b/autogpt_platform/backend/backend/integrations/webhooks/graph_lifecycle_hooks.py index 0d44a51e1abb..bc363edd08e8 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/graph_lifecycle_hooks.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/graph_lifecycle_hooks.py @@ -1,46 +1,70 @@ +import asyncio import logging -from typing import TYPE_CHECKING, Callable, Optional, cast +from typing import TYPE_CHECKING, Optional, cast, overload -from backend.data.block import BlockWebhookConfig, get_block +from backend.data.block import BlockSchema from backend.data.graph import set_node_webhook -from backend.data.model import CREDENTIALS_FIELD_NAME -from backend.integrations.webhooks import WEBHOOK_MANAGERS_BY_NAME +from backend.integrations.creds_manager import IntegrationCredentialsManager + +from . import get_webhook_manager, supports_webhooks +from .utils import setup_webhook_for_block if TYPE_CHECKING: - from backend.data.graph import GraphModel, NodeModel + from backend.data.graph import BaseGraph, GraphModel, Node, NodeModel from backend.data.model import Credentials from ._base import BaseWebhooksManager logger = logging.getLogger(__name__) +credentials_manager = IntegrationCredentialsManager() -async def on_graph_activate( - graph: "GraphModel", get_credentials: Callable[[str], "Credentials | None"] -): +async def on_graph_activate(graph: "GraphModel", user_id: str) -> "GraphModel": """ Hook to be called when a graph is activated/created. ⚠️ Assuming node entities are not re-used between graph versions, ⚠️ this hook calls `on_node_activate` on all nodes in this graph. - - Params: - get_credentials: `credentials_id` -> Credentials """ - # Compare nodes in new_graph_version with previous_graph_version + graph = await _on_graph_activate(graph, user_id) + graph.sub_graphs = await asyncio.gather( + *(_on_graph_activate(sub_graph, user_id) for sub_graph in graph.sub_graphs) + ) + return graph + + +@overload +async def _on_graph_activate(graph: "GraphModel", user_id: str) -> "GraphModel": ... + + +@overload +async def _on_graph_activate(graph: "BaseGraph", user_id: str) -> "BaseGraph": ... + + +async def _on_graph_activate(graph: "BaseGraph | GraphModel", user_id: str): + get_credentials = credentials_manager.cached_getter(user_id) updated_nodes = [] for new_node in graph.nodes: + block_input_schema = cast(BlockSchema, new_node.block.input_schema) + node_credentials = None - if creds_meta := new_node.input_default.get(CREDENTIALS_FIELD_NAME): - node_credentials = get_credentials(creds_meta["id"]) - if not node_credentials: - raise ValueError( - f"Node #{new_node.id} updated with non-existent " - f"credentials #{node_credentials}" + if ( + # Webhook-triggered blocks are only allowed to have 1 credentials input + ( + creds_field_name := next( + iter(block_input_schema.get_credentials_fields()), None ) + ) + and (creds_meta := new_node.input_default.get(creds_field_name)) + and not (node_credentials := await get_credentials(creds_meta["id"])) + ): + raise ValueError( + f"Node #{new_node.id} input '{creds_field_name}' updated with " + f"non-existent credentials #{creds_meta['id']}" + ) updated_node = await on_node_activate( - graph.user_id, new_node, credentials=node_credentials + user_id, graph.id, new_node, credentials=node_credentials ) updated_nodes.append(updated_node) @@ -48,30 +72,37 @@ async def on_graph_activate( return graph -async def on_graph_deactivate( - graph: "GraphModel", get_credentials: Callable[[str], "Credentials | None"] -): +async def on_graph_deactivate(graph: "GraphModel", user_id: str): """ Hook to be called when a graph is deactivated/deleted. ⚠️ Assuming node entities are not re-used between graph versions, ⚠️ this hook calls `on_node_deactivate` on all nodes in `graph`. - - Params: - get_credentials: `credentials_id` -> Credentials """ + get_credentials = credentials_manager.cached_getter(user_id) updated_nodes = [] for node in graph.nodes: + block_input_schema = cast(BlockSchema, node.block.input_schema) + node_credentials = None - if creds_meta := node.input_default.get(CREDENTIALS_FIELD_NAME): - node_credentials = get_credentials(creds_meta["id"]) - if not node_credentials: - logger.error( - f"Node #{node.id} referenced non-existent " - f"credentials #{creds_meta['id']}" + if ( + # Webhook-triggered blocks are only allowed to have 1 credentials input + ( + creds_field_name := next( + iter(block_input_schema.get_credentials_fields()), None ) + ) + and (creds_meta := node.input_default.get(creds_field_name)) + and not (node_credentials := await get_credentials(creds_meta["id"])) + ): + logger.error( + f"Node #{node.id} input '{creds_field_name}' referenced non-existent " + f"credentials #{creds_meta['id']}" + ) - updated_node = await on_node_deactivate(node, credentials=node_credentials) + updated_node = await on_node_deactivate( + user_id, node, credentials=node_credentials + ) updated_nodes.append(updated_node) graph.nodes = updated_nodes @@ -80,112 +111,32 @@ async def on_graph_deactivate( async def on_node_activate( user_id: str, - node: "NodeModel", + graph_id: str, + node: "Node", *, credentials: Optional["Credentials"] = None, -) -> "NodeModel": +) -> "Node": """Hook to be called when the node is activated/created""" - block = get_block(node.block_id) - if not block: - raise ValueError( - f"Node #{node.id} is instance of unknown block #{node.block_id}" + if node.block.webhook_config: + new_webhook, feedback = await setup_webhook_for_block( + user_id=user_id, + trigger_block=node.block, + trigger_config=node.input_default, + for_graph_id=graph_id, ) - - if not block.webhook_config: - return node - - provider = block.webhook_config.provider - if provider not in WEBHOOK_MANAGERS_BY_NAME: - raise ValueError( - f"Block #{block.id} has webhook_config for provider {provider} " - "which does not support webhooks" - ) - - logger.debug( - f"Activating webhook node #{node.id} with config {block.webhook_config}" - ) - - webhooks_manager = WEBHOOK_MANAGERS_BY_NAME[provider]() - - if auto_setup_webhook := isinstance(block.webhook_config, BlockWebhookConfig): - try: - resource = block.webhook_config.resource_format.format(**node.input_default) - except KeyError: - resource = None - logger.debug( - f"Constructed resource string {resource} from input {node.input_default}" - ) - else: - resource = "" # not relevant for manual webhooks - - needs_credentials = CREDENTIALS_FIELD_NAME in block.input_schema.model_fields - credentials_meta = ( - node.input_default.get(CREDENTIALS_FIELD_NAME) if needs_credentials else None - ) - event_filter_input_name = block.webhook_config.event_filter_input - has_everything_for_webhook = ( - resource is not None - and (credentials_meta or not needs_credentials) - and ( - not event_filter_input_name - or ( - event_filter_input_name in node.input_default - and any( - is_on - for is_on in node.input_default[event_filter_input_name].values() - ) - ) - ) - ) - - if has_everything_for_webhook and resource is not None: - logger.debug(f"Node #{node} has everything for a webhook!") - if credentials_meta and not credentials: - raise ValueError( - f"Cannot set up webhook for node #{node.id}: " - f"credentials #{credentials_meta['id']} not available" - ) - - if event_filter_input_name: - # Shape of the event filter is enforced in Block.__init__ - event_filter = cast(dict, node.input_default[event_filter_input_name]) - events = [ - block.webhook_config.event_format.format(event=event) - for event, enabled in event_filter.items() - if enabled is True - ] - logger.debug(f"Webhook events to subscribe to: {', '.join(events)}") - else: - events = [] - - # Find/make and attach a suitable webhook to the node - if auto_setup_webhook: - assert credentials is not None - new_webhook = await webhooks_manager.get_suitable_auto_webhook( - user_id, - credentials, - block.webhook_config.webhook_type, - resource, - events, - ) + if new_webhook: + node = await set_node_webhook(node.id, new_webhook.id) else: - # Manual webhook -> no credentials -> don't register but do create - new_webhook = await webhooks_manager.get_manual_webhook( - user_id, - node.graph_id, - block.webhook_config.webhook_type, - events, + logger.debug( + f"Node #{node.id} does not have everything for a webhook: {feedback}" ) - logger.debug(f"Acquired webhook: {new_webhook}") - return await set_node_webhook(node.id, new_webhook.id) - else: - logger.debug(f"Node #{node.id} does not have everything for a webhook") return node async def on_node_deactivate( + user_id: str, node: "NodeModel", *, credentials: Optional["Credentials"] = None, @@ -194,23 +145,19 @@ async def on_node_deactivate( """Hook to be called when node is deactivated/deleted""" logger.debug(f"Deactivating node #{node.id}") - block = get_block(node.block_id) - if not block: - raise ValueError( - f"Node #{node.id} is instance of unknown block #{node.block_id}" - ) + block = node.block if not block.webhook_config: return node provider = block.webhook_config.provider - if provider not in WEBHOOK_MANAGERS_BY_NAME: + if not supports_webhooks(provider): raise ValueError( f"Block #{block.id} has webhook_config for provider {provider} " "which does not support webhooks" ) - webhooks_manager = WEBHOOK_MANAGERS_BY_NAME[provider]() + webhooks_manager = get_webhook_manager(provider) if node.webhook_id: logger.debug(f"Node #{node.id} has webhook_id {node.webhook_id}") @@ -228,9 +175,11 @@ async def on_node_deactivate( f"Pruning{' and deregistering' if credentials else ''} " f"webhook #{webhook.id}" ) - await webhooks_manager.prune_webhook_if_dangling(webhook.id, credentials) + await webhooks_manager.prune_webhook_if_dangling( + user_id, webhook.id, credentials + ) if ( - CREDENTIALS_FIELD_NAME in block.input_schema.model_fields + cast(BlockSchema, block.input_schema).get_credentials_fields() and not credentials ): logger.warning( diff --git a/autogpt_platform/backend/backend/integrations/webhooks/slant3d.py b/autogpt_platform/backend/backend/integrations/webhooks/slant3d.py index 189ab72083ef..059f0ada0944 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/slant3d.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/slant3d.py @@ -1,12 +1,12 @@ import logging -import requests from fastapi import Request from backend.data import integrations from backend.data.model import APIKeyCredentials, Credentials from backend.integrations.providers import ProviderName from backend.integrations.webhooks._base import BaseWebhooksManager +from backend.util.request import Requests logger = logging.getLogger(__name__) @@ -39,7 +39,7 @@ async def _register_webhook( # Slant3D's API doesn't use events list, just register for all order updates payload = {"endPoint": ingress_url} - response = requests.post( + response = await Requests().post( f"{self.BASE_URL}/customer/webhookSubscribe", headers=headers, json=payload ) @@ -58,7 +58,10 @@ async def _register_webhook( @classmethod async def validate_payload( - cls, webhook: integrations.Webhook, request: Request + cls, + webhook: integrations.Webhook, + request: Request, + credentials: Credentials | None, ) -> tuple[dict, str]: """Validate incoming webhook payload from Slant3D""" diff --git a/autogpt_platform/backend/backend/integrations/webhooks/utils.py b/autogpt_platform/backend/backend/integrations/webhooks/utils.py index 87724c04cde8..d7c66efe2901 100644 --- a/autogpt_platform/backend/backend/integrations/webhooks/utils.py +++ b/autogpt_platform/backend/backend/integrations/webhooks/utils.py @@ -1,11 +1,146 @@ +import logging +from typing import TYPE_CHECKING, Optional, cast + +from pydantic import JsonValue + +from backend.integrations.creds_manager import IntegrationCredentialsManager +from backend.integrations.providers import ProviderName from backend.util.settings import Config +from . import get_webhook_manager, supports_webhooks + +if TYPE_CHECKING: + from backend.data.block import Block, BlockSchema + from backend.data.integrations import Webhook + from backend.data.model import Credentials + +logger = logging.getLogger(__name__) app_config = Config() +credentials_manager = IntegrationCredentialsManager() # TODO: add test to assert this matches the actual API route -def webhook_ingress_url(provider_name: str, webhook_id: str) -> str: +def webhook_ingress_url(provider_name: ProviderName, webhook_id: str) -> str: return ( - f"{app_config.platform_base_url}/api/integrations/{provider_name}" + f"{app_config.platform_base_url}/api/integrations/{provider_name.value}" f"/webhooks/{webhook_id}/ingress" ) + + +async def setup_webhook_for_block( + user_id: str, + trigger_block: "Block[BlockSchema, BlockSchema]", + trigger_config: dict[str, JsonValue], # = Trigger block inputs + for_graph_id: Optional[str] = None, + for_preset_id: Optional[str] = None, + credentials: Optional["Credentials"] = None, +) -> tuple["Webhook", None] | tuple[None, str]: + """ + Utility function to create (and auto-setup if possible) a webhook for a given provider. + + Returns: + Webhook: The created or found webhook object, if successful. + str: A feedback message, if any required inputs are missing. + """ + from backend.data.block import BlockWebhookConfig + + if not (trigger_base_config := trigger_block.webhook_config): + raise ValueError(f"Block #{trigger_block.id} does not have a webhook_config") + + provider = trigger_base_config.provider + if not supports_webhooks(provider): + raise NotImplementedError( + f"Block #{trigger_block.id} has webhook_config for provider {provider} " + "for which we do not have a WebhooksManager" + ) + + logger.debug( + f"Setting up webhook for block #{trigger_block.id} with config {trigger_config}" + ) + + # Check & parse the event filter input, if any + events: list[str] = [] + if event_filter_input_name := trigger_base_config.event_filter_input: + if not (event_filter := trigger_config.get(event_filter_input_name)): + return None, ( + f"Cannot set up {provider.value} webhook without event filter input: " + f"missing input for '{event_filter_input_name}'" + ) + elif not ( + # Shape of the event filter is enforced in Block.__init__ + any((event_filter := cast(dict[str, bool], event_filter)).values()) + ): + return None, ( + f"Cannot set up {provider.value} webhook without any enabled events " + f"in event filter input '{event_filter_input_name}'" + ) + + events = [ + trigger_base_config.event_format.format(event=event) + for event, enabled in event_filter.items() + if enabled is True + ] + logger.debug(f"Webhook events to subscribe to: {', '.join(events)}") + + # Check & process prerequisites for auto-setup webhooks + if auto_setup_webhook := isinstance(trigger_base_config, BlockWebhookConfig): + try: + resource = trigger_base_config.resource_format.format(**trigger_config) + except KeyError as missing_key: + return None, ( + f"Cannot auto-setup {provider.value} webhook without resource: " + f"missing input for '{missing_key}'" + ) + logger.debug( + f"Constructed resource string {resource} from input {trigger_config}" + ) + + creds_field_name = next( + # presence of this field is enforced in Block.__init__ + iter(trigger_block.input_schema.get_credentials_fields()) + ) + + if not ( + credentials_meta := cast(dict, trigger_config.get(creds_field_name, None)) + ): + return None, f"Cannot set up {provider.value} webhook without credentials" + elif not ( + credentials := credentials + or await credentials_manager.get(user_id, credentials_meta["id"]) + ): + raise ValueError( + f"Cannot set up {provider.value} webhook without credentials: " + f"credentials #{credentials_meta['id']} not found for user #{user_id}" + ) + elif credentials.provider != provider: + raise ValueError( + f"Credentials #{credentials.id} do not match provider {provider.value}" + ) + else: + # not relevant for manual webhooks: + resource = "" + credentials = None + + webhooks_manager = get_webhook_manager(provider) + + # Find/make and attach a suitable webhook to the node + if auto_setup_webhook: + assert credentials is not None + webhook = await webhooks_manager.get_suitable_auto_webhook( + user_id=user_id, + credentials=credentials, + webhook_type=trigger_base_config.webhook_type, + resource=resource, + events=events, + ) + else: + # Manual webhook -> no credentials -> don't register but do create + webhook = await webhooks_manager.get_manual_webhook( + user_id=user_id, + webhook_type=trigger_base_config.webhook_type, + events=events, + graph_id=for_graph_id, + preset_id=for_preset_id, + ) + logger.debug(f"Acquired webhook: {webhook}") + return webhook, None diff --git a/autogpt_platform/backend/backend/monitoring/__init__.py b/autogpt_platform/backend/backend/monitoring/__init__.py new file mode 100644 index 000000000000..5f9ff309179b --- /dev/null +++ b/autogpt_platform/backend/backend/monitoring/__init__.py @@ -0,0 +1,24 @@ +"""Monitoring module for platform health and alerting.""" + +from .block_error_monitor import BlockErrorMonitor, report_block_error_rates +from .late_execution_monitor import ( + LateExecutionException, + LateExecutionMonitor, + report_late_executions, +) +from .notification_monitor import ( + NotificationJobArgs, + process_existing_batches, + process_weekly_summary, +) + +__all__ = [ + "BlockErrorMonitor", + "LateExecutionMonitor", + "LateExecutionException", + "NotificationJobArgs", + "report_block_error_rates", + "report_late_executions", + "process_existing_batches", + "process_weekly_summary", +] diff --git a/autogpt_platform/backend/backend/monitoring/block_error_monitor.py b/autogpt_platform/backend/backend/monitoring/block_error_monitor.py new file mode 100644 index 000000000000..ffd2ffc8889e --- /dev/null +++ b/autogpt_platform/backend/backend/monitoring/block_error_monitor.py @@ -0,0 +1,292 @@ +"""Block error rate monitoring module.""" + +import logging +import re +from datetime import datetime, timedelta, timezone + +from pydantic import BaseModel + +from backend.data.block import get_block +from backend.data.execution import ExecutionStatus, NodeExecutionResult +from backend.util.clients import ( + get_database_manager_client, + get_notification_manager_client, +) +from backend.util.metrics import sentry_capture_error +from backend.util.settings import Config + +logger = logging.getLogger(__name__) +config = Config() + + +class BlockStatsWithSamples(BaseModel): + """Enhanced block stats with error samples.""" + + block_id: str + block_name: str + total_executions: int + failed_executions: int + error_samples: list[str] = [] + + @property + def error_rate(self) -> float: + """Calculate error rate as a percentage.""" + if self.total_executions == 0: + return 0.0 + return (self.failed_executions / self.total_executions) * 100 + + +class BlockErrorMonitor: + """Monitor block error rates and send alerts when thresholds are exceeded.""" + + def __init__(self, include_top_blocks: int | None = None): + self.config = config + self.notification_client = get_notification_manager_client() + self.include_top_blocks = ( + include_top_blocks + if include_top_blocks is not None + else config.block_error_include_top_blocks + ) + + def check_block_error_rates(self) -> str: + """Check block error rates and send Discord alerts if thresholds are exceeded.""" + try: + logger.info("Checking block error rates") + + # Get executions from the last 24 hours + end_time = datetime.now(timezone.utc) + start_time = end_time - timedelta(hours=24) + + # Use SQL aggregation to efficiently count totals and failures by block + block_stats = self._get_block_stats_from_db(start_time, end_time) + + # For blocks with high error rates, fetch error samples + threshold = self.config.block_error_rate_threshold + for block_name, stats in block_stats.items(): + if stats.total_executions >= 10 and stats.error_rate >= threshold * 100: + # Only fetch error samples for blocks that exceed threshold + error_samples = self._get_error_samples_for_block( + stats.block_id, start_time, end_time, limit=3 + ) + stats.error_samples = error_samples + + # Check thresholds and send alerts + critical_alerts = self._generate_critical_alerts(block_stats, threshold) + + if critical_alerts: + msg = "Block Error Rate Alert:\n\n" + "\n\n".join(critical_alerts) + self.notification_client.discord_system_alert(msg) + logger.info( + f"Sent block error rate alert for {len(critical_alerts)} blocks" + ) + return f"Alert sent for {len(critical_alerts)} blocks with high error rates" + + # If no critical alerts, check if we should show top blocks + if self.include_top_blocks > 0: + top_blocks_msg = self._generate_top_blocks_alert( + block_stats, start_time, end_time + ) + if top_blocks_msg: + self.notification_client.discord_system_alert(top_blocks_msg) + logger.info("Sent top blocks summary") + return "Sent top blocks summary" + + logger.info("No blocks exceeded error rate threshold") + return "No errors reported for today" + + except Exception as e: + logger.exception(f"Error checking block error rates: {e}") + + error = Exception(f"Error checking block error rates: {e}") + msg = str(error) + sentry_capture_error(error) + self.notification_client.discord_system_alert(msg) + return msg + + def _get_block_stats_from_db( + self, start_time: datetime, end_time: datetime + ) -> dict[str, BlockStatsWithSamples]: + """Get block execution stats using efficient SQL aggregation.""" + + result = get_database_manager_client().get_block_error_stats( + start_time, end_time + ) + + block_stats = {} + for stats in result: + block_name = b.name if (b := get_block(stats.block_id)) else "Unknown" + + block_stats[block_name] = BlockStatsWithSamples( + block_id=stats.block_id, + block_name=block_name, + total_executions=stats.total_executions, + failed_executions=stats.failed_executions, + error_samples=[], + ) + + return block_stats + + def _generate_critical_alerts( + self, block_stats: dict[str, BlockStatsWithSamples], threshold: float + ) -> list[str]: + """Generate alerts for blocks that exceed the error rate threshold.""" + alerts = [] + + for block_name, stats in block_stats.items(): + if stats.total_executions >= 10 and stats.error_rate >= threshold * 100: + error_groups = self._group_similar_errors(stats.error_samples) + + alert_msg = ( + f"🚨 Block '{block_name}' has {stats.error_rate:.1f}% error rate " + f"({stats.failed_executions}/{stats.total_executions}) in the last 24 hours" + ) + + if error_groups: + alert_msg += "\n\n📊 Error Types:" + for error_pattern, count in error_groups.items(): + alert_msg += f"\n• {error_pattern} ({count}x)" + + alerts.append(alert_msg) + + return alerts + + def _generate_top_blocks_alert( + self, + block_stats: dict[str, BlockStatsWithSamples], + start_time: datetime, + end_time: datetime, + ) -> str | None: + """Generate top blocks summary when no critical alerts exist.""" + top_error_blocks = sorted( + [ + (name, stats) + for name, stats in block_stats.items() + if stats.total_executions >= 10 and stats.failed_executions > 0 + ], + key=lambda x: x[1].failed_executions, + reverse=True, + )[: self.include_top_blocks] + + if not top_error_blocks: + return "✅ No errors reported for today - all blocks are running smoothly!" + + # Get error samples for top blocks + for block_name, stats in top_error_blocks: + if not stats.error_samples: + stats.error_samples = self._get_error_samples_for_block( + stats.block_id, start_time, end_time, limit=2 + ) + + count_text = ( + f"top {self.include_top_blocks}" if self.include_top_blocks > 1 else "top" + ) + alert_msg = f"📊 Daily Error Summary - {count_text} blocks with most errors:" + for block_name, stats in top_error_blocks: + alert_msg += f"\n• {block_name}: {stats.failed_executions} errors ({stats.error_rate:.1f}% of {stats.total_executions})" + + if stats.error_samples: + error_groups = self._group_similar_errors(stats.error_samples) + if error_groups: + # Show most common error + most_common_error = next(iter(error_groups.items())) + alert_msg += f"\n └ Most common: {most_common_error[0]}" + + return alert_msg + + def _get_error_samples_for_block( + self, block_id: str, start_time: datetime, end_time: datetime, limit: int = 3 + ) -> list[str]: + """Get error samples for a specific block - just a few recent ones.""" + # Only fetch a small number of recent failed executions for this specific block + executions = get_database_manager_client().get_node_executions( + block_ids=[block_id], + statuses=[ExecutionStatus.FAILED], + created_time_gte=start_time, + created_time_lte=end_time, + limit=limit, # Just get the limit we need + ) + + error_samples = [] + for execution in executions: + if error_message := self._extract_error_message(execution): + masked_error = self._mask_sensitive_data(error_message) + error_samples.append(masked_error) + + if len(error_samples) >= limit: # Stop once we have enough samples + break + + return error_samples + + def _extract_error_message(self, execution: NodeExecutionResult) -> str | None: + """Extract error message from execution output.""" + try: + if execution.output_data and ( + error_msg := execution.output_data.get("error") + ): + return str(error_msg[0]) + return None + except Exception: + return None + + def _mask_sensitive_data(self, error_message): + """Mask sensitive data in error messages to enable grouping.""" + if not error_message: + return "" + + # Convert to string if not already + error_str = str(error_message) + + # Mask numbers (replace with X) + error_str = re.sub(r"\d+", "X", error_str) + + # Mask all caps words (likely constants/IDs) + error_str = re.sub(r"\b[A-Z_]{3,}\b", "MASKED", error_str) + + # Mask words with underscores (likely internal variables) + error_str = re.sub(r"\b\w*_\w*\b", "MASKED", error_str) + + # Mask UUIDs and long alphanumeric strings + error_str = re.sub( + r"\b[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\b", + "UUID", + error_str, + ) + error_str = re.sub(r"\b[a-f0-9]{20,}\b", "HASH", error_str) + + # Mask file paths + error_str = re.sub(r"(/[^/\s]+)+", "/MASKED/path", error_str) + + # Mask URLs + error_str = re.sub(r"https?://[^\s]+", "URL", error_str) + + # Mask email addresses + error_str = re.sub( + r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b", "EMAIL", error_str + ) + + # Truncate if too long + if len(error_str) > 100: + error_str = error_str[:97] + "..." + + return error_str.strip() + + def _group_similar_errors(self, error_samples): + """Group similar error messages and return counts.""" + if not error_samples: + return {} + + error_groups = {} + for error in error_samples: + if error in error_groups: + error_groups[error] += 1 + else: + error_groups[error] = 1 + + # Sort by frequency, most common first + return dict(sorted(error_groups.items(), key=lambda x: x[1], reverse=True)) + + +def report_block_error_rates(include_top_blocks: int | None = None): + """Check block error rates and send Discord alerts if thresholds are exceeded.""" + monitor = BlockErrorMonitor(include_top_blocks=include_top_blocks) + return monitor.check_block_error_rates() diff --git a/autogpt_platform/backend/backend/monitoring/late_execution_monitor.py b/autogpt_platform/backend/backend/monitoring/late_execution_monitor.py new file mode 100644 index 000000000000..1e0c99cac384 --- /dev/null +++ b/autogpt_platform/backend/backend/monitoring/late_execution_monitor.py @@ -0,0 +1,110 @@ +"""Late execution monitoring module.""" + +import logging +from datetime import datetime, timedelta, timezone + +from backend.data.execution import ExecutionStatus +from backend.util.clients import ( + get_database_manager_client, + get_notification_manager_client, +) +from backend.util.metrics import sentry_capture_error +from backend.util.settings import Config + +logger = logging.getLogger(__name__) +config = Config() + + +class LateExecutionException(Exception): + """Exception raised when late executions are detected.""" + + pass + + +class LateExecutionMonitor: + """Monitor late executions and send alerts when thresholds are exceeded.""" + + def __init__(self): + self.config = config + self.notification_client = get_notification_manager_client() + + def check_late_executions(self) -> str: + """Check for late executions and send alerts if found.""" + + # Check for QUEUED executions + queued_late_executions = get_database_manager_client().get_graph_executions( + statuses=[ExecutionStatus.QUEUED], + created_time_gte=datetime.now(timezone.utc) + - timedelta( + seconds=self.config.execution_late_notification_checkrange_secs + ), + created_time_lte=datetime.now(timezone.utc) + - timedelta(seconds=self.config.execution_late_notification_threshold_secs), + limit=1000, + ) + + # Check for RUNNING executions stuck for more than 24 hours + running_late_executions = get_database_manager_client().get_graph_executions( + statuses=[ExecutionStatus.RUNNING], + created_time_gte=datetime.now(timezone.utc) + - timedelta(hours=24) + - timedelta( + seconds=self.config.execution_late_notification_checkrange_secs + ), + created_time_lte=datetime.now(timezone.utc) - timedelta(hours=24), + limit=1000, + ) + + all_late_executions = queued_late_executions + running_late_executions + + if not all_late_executions: + return "No late executions detected." + + # Sort by created time (oldest first) + all_late_executions.sort(key=lambda x: x.started_at) + + num_total_late = len(all_late_executions) + num_queued = len(queued_late_executions) + num_running = len(running_late_executions) + num_users = len(set([r.user_id for r in all_late_executions])) + + # Truncate to max entries + tuncate_size = 5 + truncated_executions = all_late_executions[:tuncate_size] + was_truncated = num_total_late > tuncate_size + + late_execution_details = [ + f"* `Execution ID: {exec.id}, Graph ID: {exec.graph_id}v{exec.graph_version}, User ID: {exec.user_id}, Status: {exec.status}, Created At: {exec.started_at.isoformat()}`" + for exec in truncated_executions + ] + + message_parts = [ + f"Late executions detected: {num_total_late} total late executions ({num_queued} QUEUED, {num_running} RUNNING) from {num_users} users.", + f"QUEUED executions have been waiting for more than {self.config.execution_late_notification_threshold_secs} seconds.", + "RUNNING executions have been running for more than 24 hours.", + "Please check the executor status.", + ] + + if was_truncated: + message_parts.append( + f"\nShowing first {tuncate_size} of {num_total_late} late executions:" + ) + else: + message_parts.append("\nDetails:") + + error_message = ( + "\n".join(message_parts) + "\n" + "\n".join(late_execution_details) + ) + + error = LateExecutionException(error_message) + msg = str(error) + + sentry_capture_error(error) + self.notification_client.discord_system_alert(msg) + return msg + + +def report_late_executions() -> str: + """Check for late executions and send Discord alerts if found.""" + monitor = LateExecutionMonitor() + return monitor.check_late_executions() diff --git a/autogpt_platform/backend/backend/monitoring/notification_monitor.py b/autogpt_platform/backend/backend/monitoring/notification_monitor.py new file mode 100644 index 000000000000..db69b4d9fb42 --- /dev/null +++ b/autogpt_platform/backend/backend/monitoring/notification_monitor.py @@ -0,0 +1,38 @@ +"""Notification processing monitoring module.""" + +import logging + +from prisma.enums import NotificationType +from pydantic import BaseModel + +from backend.util.clients import get_notification_manager_client + +logger = logging.getLogger(__name__) + + +class NotificationJobArgs(BaseModel): + notification_types: list[NotificationType] + cron: str + + +def process_existing_batches(**kwargs): + """Process existing notification batches.""" + args = NotificationJobArgs(**kwargs) + try: + logging.info( + f"Processing existing batches for notification type {args.notification_types}" + ) + get_notification_manager_client().process_existing_batches( + args.notification_types + ) + except Exception as e: + logger.exception(f"Error processing existing batches: {e}") + + +def process_weekly_summary(**kwargs): + """Process weekly summary notifications.""" + try: + logging.info("Processing weekly summary") + get_notification_manager_client().queue_weekly_summary() + except Exception as e: + logger.exception(f"Error processing weekly summary: {e}") diff --git a/autogpt_platform/backend/backend/notification.py b/autogpt_platform/backend/backend/notification.py new file mode 100644 index 000000000000..4a349a4929d5 --- /dev/null +++ b/autogpt_platform/backend/backend/notification.py @@ -0,0 +1,15 @@ +from backend.app import run_processes +from backend.notifications.notifications import NotificationManager + + +def main(): + """ + Run the AutoGPT-server Notification Service. + """ + run_processes( + NotificationManager(), + ) + + +if __name__ == "__main__": + main() diff --git a/autogpt_platform/backend/backend/notifications/__init__.py b/autogpt_platform/backend/backend/notifications/__init__.py new file mode 100644 index 000000000000..804449e10610 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/__init__.py @@ -0,0 +1,6 @@ +from .notifications import NotificationManager, NotificationManagerClient + +__all__ = [ + "NotificationManager", + "NotificationManagerClient", +] diff --git a/autogpt_platform/backend/backend/notifications/email.py b/autogpt_platform/backend/backend/notifications/email.py new file mode 100644 index 000000000000..84202ea6a97a --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/email.py @@ -0,0 +1,149 @@ +import logging +import pathlib + +from postmarker.core import PostmarkClient +from postmarker.models.emails import EmailManager +from prisma.enums import NotificationType +from pydantic import BaseModel + +from backend.data.notifications import ( + NotificationDataType_co, + NotificationEventModel, + NotificationTypeOverride, +) +from backend.util.settings import Settings +from backend.util.text import TextFormatter + +logger = logging.getLogger(__name__) +settings = Settings() + + +# The following is a workaround to get the type checker to recognize the EmailManager type +# This is a temporary solution and should be removed once the Postmark library is updated +# to support type annotations. +class TypedPostmarkClient(PostmarkClient): + emails: EmailManager + + +class Template(BaseModel): + subject_template: str + body_template: str + base_template: str + + +class EmailSender: + def __init__(self): + if settings.secrets.postmark_server_api_token: + self.postmark = TypedPostmarkClient( + server_token=settings.secrets.postmark_server_api_token + ) + else: + logger.warning( + "Postmark server API token not found, email sending disabled" + ) + self.postmark = None + self.formatter = TextFormatter() + + def send_templated( + self, + notification: NotificationType, + user_email: str, + data: ( + NotificationEventModel[NotificationDataType_co] + | list[NotificationEventModel[NotificationDataType_co]] + ), + user_unsub_link: str | None = None, + ): + """Send an email to a user using a template pulled from the notification type""" + if not self.postmark: + logger.warning("Postmark client not initialized, email not sent") + return + template = self._get_template(notification) + + base_url = ( + settings.config.frontend_base_url or settings.config.platform_base_url + ) + + # Handle the case when data is a list + template_data = data + if isinstance(data, list): + # Create a dictionary with a 'notifications' key containing the list + template_data = {"notifications": data} + + try: + subject, full_message = self.formatter.format_email( + base_template=template.base_template, + subject_template=template.subject_template, + content_template=template.body_template, + data=template_data, + unsubscribe_link=f"{base_url}/profile/settings", + ) + except Exception as e: + logger.error(f"Error formatting full message: {e}") + raise e + + # Check email size (Postmark limit is 5MB = 5,242,880 characters) + email_size = len(full_message) + if email_size > 5_000_000: # Leave some buffer + logger.warning( + f"Email size ({email_size} chars) exceeds safe limit. " + f"This should have been chunked before calling send_templated." + ) + raise ValueError( + f"Email body too large: {email_size} characters (limit: 5,242,880)" + ) + + logger.debug(f"Sending email with size: {email_size} characters") + + self._send_email( + user_email=user_email, + user_unsubscribe_link=user_unsub_link, + subject=subject, + body=full_message, + ) + + def _get_template(self, notification: NotificationType): + # convert the notification type to a notification type override + notification_type_override = NotificationTypeOverride(notification) + # find the template in templates/name.html (the .template returns with the .html) + template_path = f"templates/{notification_type_override.template}.jinja2" + logger.debug( + f"Template full path: {pathlib.Path(__file__).parent / template_path}" + ) + base_template_path = "templates/base.html.jinja2" + with open(pathlib.Path(__file__).parent / base_template_path, "r") as file: + base_template = file.read() + with open(pathlib.Path(__file__).parent / template_path, "r") as file: + template = file.read() + return Template( + subject_template=notification_type_override.subject, + body_template=template, + base_template=base_template, + ) + + def _send_email( + self, + user_email: str, + subject: str, + body: str, + user_unsubscribe_link: str | None = None, + ): + if not self.postmark: + logger.warning("Email tried to send without postmark configured") + return + logger.debug(f"Sending email to {user_email} with subject {subject}") + self.postmark.emails.send( + From=settings.config.postmark_sender_email, + To=user_email, + Subject=subject, + HtmlBody=body, + # Headers default to None internally so this is fine + Headers=( + { + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + "List-Unsubscribe": f"<{user_unsubscribe_link}>", + } + if user_unsubscribe_link + else None + ), + ) diff --git a/autogpt_platform/backend/backend/notifications/notifications.py b/autogpt_platform/backend/backend/notifications/notifications.py new file mode 100644 index 000000000000..399e4268374a --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/notifications.py @@ -0,0 +1,934 @@ +import asyncio +import logging +from datetime import datetime, timedelta, timezone +from typing import Awaitable, Callable + +import aio_pika +from prisma.enums import NotificationType + +from backend.data import rabbitmq +from backend.data.notifications import ( + BaseEventModel, + BaseSummaryData, + BaseSummaryParams, + DailySummaryData, + DailySummaryParams, + NotificationEventModel, + NotificationResult, + NotificationTypeOverride, + QueueType, + SummaryParamsEventModel, + WeeklySummaryData, + WeeklySummaryParams, + get_batch_delay, + get_notif_data_type, + get_summary_params_type, +) +from backend.data.rabbitmq import Exchange, ExchangeType, Queue, RabbitMQConfig +from backend.data.user import generate_unsubscribe_link +from backend.notifications.email import EmailSender +from backend.util.clients import get_database_manager_async_client +from backend.util.logging import TruncatedLogger +from backend.util.metrics import DiscordChannel, discord_send_alert +from backend.util.retry import continuous_retry +from backend.util.service import ( + AppService, + AppServiceClient, + UnhealthyServiceError, + endpoint_to_sync, + expose, +) +from backend.util.settings import Settings + +logger = TruncatedLogger(logging.getLogger(__name__), "[NotificationManager]") +settings = Settings() + + +NOTIFICATION_EXCHANGE = Exchange(name="notifications", type=ExchangeType.TOPIC) +DEAD_LETTER_EXCHANGE = Exchange(name="dead_letter", type=ExchangeType.TOPIC) +EXCHANGES = [NOTIFICATION_EXCHANGE, DEAD_LETTER_EXCHANGE] + + +def create_notification_config() -> RabbitMQConfig: + """Create RabbitMQ configuration for notifications""" + + queues = [ + # Main notification queues + Queue( + name="immediate_notifications", + exchange=NOTIFICATION_EXCHANGE, + routing_key="notification.immediate.#", + arguments={ + "x-dead-letter-exchange": DEAD_LETTER_EXCHANGE.name, + "x-dead-letter-routing-key": "failed.immediate", + }, + ), + Queue( + name="admin_notifications", + exchange=NOTIFICATION_EXCHANGE, + routing_key="notification.admin.#", + arguments={ + "x-dead-letter-exchange": DEAD_LETTER_EXCHANGE.name, + "x-dead-letter-routing-key": "failed.admin", + }, + ), + # Summary notification queues + Queue( + name="summary_notifications", + exchange=NOTIFICATION_EXCHANGE, + routing_key="notification.summary.#", + arguments={ + "x-dead-letter-exchange": DEAD_LETTER_EXCHANGE.name, + "x-dead-letter-routing-key": "failed.summary", + }, + ), + # Batch Queue + Queue( + name="batch_notifications", + exchange=NOTIFICATION_EXCHANGE, + routing_key="notification.batch.#", + arguments={ + "x-dead-letter-exchange": DEAD_LETTER_EXCHANGE.name, + "x-dead-letter-routing-key": "failed.batch", + }, + ), + # Failed notifications queue + Queue( + name="failed_notifications", + exchange=DEAD_LETTER_EXCHANGE, + routing_key="failed.#", + ), + ] + + return RabbitMQConfig( + exchanges=EXCHANGES, + queues=queues, + ) + + +def get_routing_key(event_type: NotificationType) -> str: + strategy = NotificationTypeOverride(event_type).strategy + """Get the appropriate routing key for an event""" + if strategy == QueueType.IMMEDIATE: + return f"notification.immediate.{event_type.value}" + elif strategy == QueueType.BACKOFF: + return f"notification.backoff.{event_type.value}" + elif strategy == QueueType.ADMIN: + return f"notification.admin.{event_type.value}" + elif strategy == QueueType.BATCH: + return f"notification.batch.{event_type.value}" + elif strategy == QueueType.SUMMARY: + return f"notification.summary.{event_type.value}" + return f"notification.{event_type.value}" + + +def queue_notification(event: NotificationEventModel) -> NotificationResult: + """Queue a notification - exposed method for other services to call""" + try: + logger.debug(f"Received Request to queue {event=}") + + exchange = "notifications" + routing_key = get_routing_key(event.type) + + from backend.util.clients import get_notification_queue + + queue = get_notification_queue() + queue.publish_message( + routing_key=routing_key, + message=event.model_dump_json(), + exchange=next(ex for ex in EXCHANGES if ex.name == exchange), + ) + + return NotificationResult( + success=True, + message=f"Notification queued with routing key: {routing_key}", + ) + + except Exception as e: + logger.exception(f"Error queueing notification: {e}") + return NotificationResult(success=False, message=str(e)) + + +async def queue_notification_async(event: NotificationEventModel) -> NotificationResult: + """Queue a notification - exposed method for other services to call""" + try: + logger.debug(f"Received Request to queue {event=}") + + exchange = "notifications" + routing_key = get_routing_key(event.type) + + from backend.util.clients import get_async_notification_queue + + queue = await get_async_notification_queue() + await queue.publish_message( + routing_key=routing_key, + message=event.model_dump_json(), + exchange=next(ex for ex in EXCHANGES if ex.name == exchange), + ) + + return NotificationResult( + success=True, + message=f"Notification queued with routing key: {routing_key}", + ) + + except Exception as e: + logger.exception(f"Error queueing notification: {e}") + return NotificationResult(success=False, message=str(e)) + + +class NotificationManager(AppService): + """Service for handling notifications with batching support""" + + def __init__(self): + super().__init__() + self.rabbitmq_config = create_notification_config() + self.running = True + self.email_sender = EmailSender() + + @property + def rabbit(self) -> rabbitmq.AsyncRabbitMQ: + """Access the RabbitMQ service. Will raise if not configured.""" + if not hasattr(self, "rabbitmq_service") or not self.rabbitmq_service: + raise UnhealthyServiceError("RabbitMQ not configured for this service") + return self.rabbitmq_service + + @property + def rabbit_config(self) -> rabbitmq.RabbitMQConfig: + """Access the RabbitMQ config. Will raise if not configured.""" + if not self.rabbitmq_config: + raise UnhealthyServiceError("RabbitMQ not configured for this service") + return self.rabbitmq_config + + async def health_check(self) -> str: + # Service is unhealthy if RabbitMQ is not ready + if not hasattr(self, "rabbitmq_service") or not self.rabbitmq_service: + raise UnhealthyServiceError("RabbitMQ not configured for this service") + if not self.rabbitmq_service.is_ready: + raise UnhealthyServiceError("RabbitMQ channel is not ready") + return await super().health_check() + + @classmethod + def get_port(cls) -> int: + return settings.config.notification_service_port + + @expose + async def queue_weekly_summary(self): + # Use the existing event loop instead of creating a new one with asyncio.run() + asyncio.create_task(self._queue_weekly_summary()) + + async def _queue_weekly_summary(self): + """Process weekly summary for specified notification types""" + try: + logger.info("Processing weekly summary queuing operation") + processed_count = 0 + current_time = datetime.now(tz=timezone.utc) + start_time = current_time - timedelta(days=7) + logger.info( + f"Querying for active users between {start_time} and {current_time}" + ) + users = await get_database_manager_async_client().get_active_user_ids_in_timerange( + end_time=current_time.isoformat(), + start_time=start_time.isoformat(), + ) + logger.info(f"Found {len(users)} active users in the last 7 days") + for user in users: + await self._queue_scheduled_notification( + SummaryParamsEventModel( + user_id=user, + type=NotificationType.WEEKLY_SUMMARY, + data=WeeklySummaryParams( + start_date=start_time, + end_date=current_time, + ), + ), + ) + processed_count += 1 + + logger.info(f"Processed {processed_count} weekly summaries into queue") + + except Exception as e: + logger.exception(f"Error processing weekly summary: {e}") + + @expose + async def process_existing_batches( + self, notification_types: list[NotificationType] + ): + # Use the existing event loop instead of creating a new process + asyncio.create_task(self._process_existing_batches(notification_types)) + + async def _process_existing_batches( + self, notification_types: list[NotificationType] + ): + """Process existing batches for specified notification types""" + try: + processed_count = 0 + current_time = datetime.now(tz=timezone.utc) + + for notification_type in notification_types: + # Get all batches for this notification type + batches = ( + await get_database_manager_async_client().get_all_batches_by_type( + notification_type + ) + ) + + for batch in batches: + # Check if batch has aged out + oldest_message = await get_database_manager_async_client().get_user_notification_oldest_message_in_batch( + batch.user_id, notification_type + ) + + if not oldest_message: + # this should never happen + logger.error( + f"Batch for user {batch.user_id} and type {notification_type} has no oldest message whichshould never happen!!!!!!!!!!!!!!!!" + ) + continue + + max_delay = get_batch_delay(notification_type) + + # If batch has aged out, process it + if oldest_message.created_at + max_delay < current_time: + recipient_email = await get_database_manager_async_client().get_user_email_by_id( + batch.user_id + ) + + if not recipient_email: + logger.error( + f"User email not found for user {batch.user_id}" + ) + continue + + should_send = await self._should_email_user_based_on_preference( + batch.user_id, notification_type + ) + + if not should_send: + logger.debug( + f"User {batch.user_id} does not want to receive {notification_type} notifications" + ) + # Clear the batch + await get_database_manager_async_client().empty_user_notification_batch( + batch.user_id, notification_type + ) + continue + + batch_data = await get_database_manager_async_client().get_user_notification_batch( + batch.user_id, notification_type + ) + + if not batch_data or not batch_data.notifications: + logger.error( + f"Batch data not found for user {batch.user_id}" + ) + # Clear the batch + await get_database_manager_async_client().empty_user_notification_batch( + batch.user_id, notification_type + ) + continue + + unsub_link = generate_unsubscribe_link(batch.user_id) + events = [] + for db_event in batch_data.notifications: + try: + events.append( + NotificationEventModel[ + get_notif_data_type(db_event.type) + ].model_validate( + { + "user_id": batch.user_id, + "type": db_event.type, + "data": db_event.data, + "created_at": db_event.created_at, + } + ) + ) + except Exception as e: + logger.error( + f"Error parsing notification event: {e=}, {db_event=}" + ) + continue + logger.info(f"{events=}") + + self.email_sender.send_templated( + notification=notification_type, + user_email=recipient_email, + data=events, + user_unsub_link=unsub_link, + ) + + # Clear the batch + await get_database_manager_async_client().empty_user_notification_batch( + batch.user_id, notification_type + ) + + processed_count += 1 + + logger.info(f"Processed {processed_count} aged batches") + return { + "success": True, + "processed_count": processed_count, + "notification_types": [nt.value for nt in notification_types], + "timestamp": current_time.isoformat(), + } + + except Exception as e: + logger.exception(f"Error processing batches: {e}") + return { + "success": False, + "error": str(e), + "notification_types": [nt.value for nt in notification_types], + "timestamp": datetime.now(tz=timezone.utc).isoformat(), + } + + @expose + async def discord_system_alert( + self, content: str, channel: DiscordChannel = DiscordChannel.PLATFORM + ): + await discord_send_alert(content, channel) + + async def _queue_scheduled_notification(self, event: SummaryParamsEventModel): + """Queue a scheduled notification - exposed method for other services to call""" + try: + logger.info( + f"Queueing scheduled notification type={event.type} user_id={event.user_id}" + ) + + exchange = "notifications" + routing_key = get_routing_key(event.type) + logger.info(f"Using routing key: {routing_key}") + + # Publish to RabbitMQ + await self.rabbit.publish_message( + routing_key=routing_key, + message=event.model_dump_json(), + exchange=next(ex for ex in EXCHANGES if ex.name == exchange), + ) + logger.info(f"Successfully queued notification for user {event.user_id}") + + except Exception as e: + logger.exception(f"Error queueing notification: {e}") + + async def _should_email_user_based_on_preference( + self, user_id: str, event_type: NotificationType + ) -> bool: + """Check if a user wants to receive a notification based on their preferences and email verification status""" + validated_email = ( + await get_database_manager_async_client().get_user_email_verification( + user_id + ) + ) + preference = ( + await get_database_manager_async_client().get_user_notification_preference( + user_id + ) + ).preferences.get(event_type, True) + # only if both are true, should we email this person + return validated_email and preference + + async def _gather_summary_data( + self, user_id: str, event_type: NotificationType, params: BaseSummaryParams + ) -> BaseSummaryData: + """Gathers the data to build a summary notification""" + + logger.info( + f"Gathering summary data for {user_id} and {event_type} with {params=}" + ) + + try: + # Get summary data from the database + summary_data = await get_database_manager_async_client().get_user_execution_summary_data( + user_id=user_id, + start_time=params.start_date, + end_time=params.end_date, + ) + + # Extract data from summary + total_credits_used = summary_data.total_credits_used + total_executions = summary_data.total_executions + most_used_agent = summary_data.most_used_agent + successful_runs = summary_data.successful_runs + failed_runs = summary_data.failed_runs + total_execution_time = summary_data.total_execution_time + average_execution_time = summary_data.average_execution_time + cost_breakdown = summary_data.cost_breakdown + + if event_type == NotificationType.DAILY_SUMMARY and isinstance( + params, DailySummaryParams + ): + return DailySummaryData( + total_credits_used=total_credits_used, + total_executions=total_executions, + most_used_agent=most_used_agent, + total_execution_time=total_execution_time, + successful_runs=successful_runs, + failed_runs=failed_runs, + average_execution_time=average_execution_time, + cost_breakdown=cost_breakdown, + date=params.date, + ) + elif event_type == NotificationType.WEEKLY_SUMMARY and isinstance( + params, WeeklySummaryParams + ): + return WeeklySummaryData( + total_credits_used=total_credits_used, + total_executions=total_executions, + most_used_agent=most_used_agent, + total_execution_time=total_execution_time, + successful_runs=successful_runs, + failed_runs=failed_runs, + average_execution_time=average_execution_time, + cost_breakdown=cost_breakdown, + start_date=params.start_date, + end_date=params.end_date, + ) + else: + raise ValueError("Invalid event type or params") + + except Exception as e: + logger.error(f"Failed to gather summary data: {e}") + # Return sensible defaults in case of error + if event_type == NotificationType.DAILY_SUMMARY and isinstance( + params, DailySummaryParams + ): + return DailySummaryData( + total_credits_used=0.0, + total_executions=0, + most_used_agent="No data available", + total_execution_time=0.0, + successful_runs=0, + failed_runs=0, + average_execution_time=0.0, + cost_breakdown={}, + date=params.date, + ) + elif event_type == NotificationType.WEEKLY_SUMMARY and isinstance( + params, WeeklySummaryParams + ): + return WeeklySummaryData( + total_credits_used=0.0, + total_executions=0, + most_used_agent="No data available", + total_execution_time=0.0, + successful_runs=0, + failed_runs=0, + average_execution_time=0.0, + cost_breakdown={}, + start_date=params.start_date, + end_date=params.end_date, + ) + else: + raise ValueError("Invalid event type or params") from e + + async def _should_batch( + self, user_id: str, event_type: NotificationType, event: NotificationEventModel + ) -> bool: + + await get_database_manager_async_client().create_or_add_to_user_notification_batch( + user_id, event_type, event + ) + + oldest_message = await get_database_manager_async_client().get_user_notification_oldest_message_in_batch( + user_id, event_type + ) + if not oldest_message: + logger.error( + f"Batch for user {user_id} and type {event_type} has no oldest message whichshould never happen!!!!!!!!!!!!!!!!" + ) + return False + oldest_age = oldest_message.created_at + + max_delay = get_batch_delay(event_type) + + if oldest_age + max_delay < datetime.now(tz=timezone.utc): + logger.info(f"Batch for user {user_id} and type {event_type} is old enough") + return True + logger.info( + f"Batch for user {user_id} and type {event_type} is not old enough: {oldest_age + max_delay} < {datetime.now(tz=timezone.utc)} max_delay={max_delay}" + ) + return False + + def _parse_message(self, message: str) -> NotificationEventModel | None: + try: + event = BaseEventModel.model_validate_json(message) + return NotificationEventModel[ + get_notif_data_type(event.type) + ].model_validate_json(message) + except Exception as e: + logger.error(f"Error parsing message due to non matching schema {e}") + return None + + async def _process_admin_message(self, message: str) -> bool: + """Process a single notification, sending to an admin, returning whether to put into the failed queue""" + try: + event = self._parse_message(message) + if not event: + return False + logger.debug(f"Processing notification for admin: {event}") + recipient_email = settings.config.refund_notification_email + self.email_sender.send_templated(event.type, recipient_email, event) + return True + except Exception as e: + logger.exception(f"Error processing notification for admin queue: {e}") + return False + + async def _process_immediate(self, message: str) -> bool: + """Process a single notification immediately, returning whether to put into the failed queue""" + try: + event = self._parse_message(message) + if not event: + return False + logger.debug(f"Processing immediate notification: {event}") + + recipient_email = ( + await get_database_manager_async_client().get_user_email_by_id( + event.user_id + ) + ) + if not recipient_email: + logger.error(f"User email not found for user {event.user_id}") + return False + + should_send = await self._should_email_user_based_on_preference( + event.user_id, event.type + ) + if not should_send: + logger.debug( + f"User {event.user_id} does not want to receive {event.type} notifications" + ) + return True + + unsub_link = generate_unsubscribe_link(event.user_id) + + self.email_sender.send_templated( + notification=event.type, + user_email=recipient_email, + data=event, + user_unsub_link=unsub_link, + ) + return True + except Exception as e: + logger.exception(f"Error processing notification for immediate queue: {e}") + return False + + async def _process_batch(self, message: str) -> bool: + """Process a single notification with a batching strategy, returning whether to put into the failed queue""" + try: + event = self._parse_message(message) + if not event: + return False + logger.info(f"Processing batch notification: {event}") + + recipient_email = ( + await get_database_manager_async_client().get_user_email_by_id( + event.user_id + ) + ) + if not recipient_email: + logger.error(f"User email not found for user {event.user_id}") + return False + + should_send = await self._should_email_user_based_on_preference( + event.user_id, event.type + ) + if not should_send: + logger.info( + f"User {event.user_id} does not want to receive {event.type} notifications" + ) + return True + + should_send = await self._should_batch(event.user_id, event.type, event) + + if not should_send: + logger.info("Batch not old enough to send") + return False + batch = ( + await get_database_manager_async_client().get_user_notification_batch( + event.user_id, event.type + ) + ) + if not batch or not batch.notifications: + logger.error(f"Batch not found for user {event.user_id}") + return False + unsub_link = generate_unsubscribe_link(event.user_id) + + batch_messages = [ + NotificationEventModel[ + get_notif_data_type(db_event.type) + ].model_validate( + { + "user_id": event.user_id, + "type": db_event.type, + "data": db_event.data, + "created_at": db_event.created_at, + } + ) + for db_event in batch.notifications + ] + + # Split batch into chunks to avoid exceeding email size limits + # Start with a reasonable chunk size and adjust dynamically + MAX_EMAIL_SIZE = 4_500_000 # 4.5MB to leave buffer under 5MB limit + chunk_size = 100 # Initial chunk size + successfully_sent_count = 0 + failed_indices = [] + + i = 0 + while i < len(batch_messages): + # Try progressively smaller chunks if needed + chunk_sent = False + for attempt_size in [chunk_size, 50, 25, 10, 5, 1]: + chunk = batch_messages[i : i + attempt_size] + + try: + # Try to render the email to check its size + template = self.email_sender._get_template(event.type) + _, test_message = self.email_sender.formatter.format_email( + base_template=template.base_template, + subject_template=template.subject_template, + content_template=template.body_template, + data={"notifications": chunk}, + unsubscribe_link=f"{self.email_sender.formatter.env.globals.get('base_url', '')}/profile/settings", + ) + + if len(test_message) < MAX_EMAIL_SIZE: + # Size is acceptable, send the email + logger.info( + f"Sending email with {len(chunk)} notifications " + f"(size: {len(test_message):,} chars)" + ) + + self.email_sender.send_templated( + notification=event.type, + user_email=recipient_email, + data=chunk, + user_unsub_link=unsub_link, + ) + + # Track successful sends + successfully_sent_count += len(chunk) + + # Update chunk_size for next iteration based on success + if ( + attempt_size == chunk_size + and len(test_message) < MAX_EMAIL_SIZE * 0.7 + ): + # If we're well under limit, try larger chunks next time + chunk_size = min(chunk_size + 10, 100) + elif len(test_message) > MAX_EMAIL_SIZE * 0.9: + # If we're close to limit, use smaller chunks + chunk_size = max(attempt_size - 10, 1) + + i += len(chunk) + chunk_sent = True + break + except Exception as e: + if attempt_size == 1: + # Even single notification is too large + logger.error( + f"Single notification too large to send: {e}. " + f"Skipping notification at index {i}" + ) + failed_indices.append(i) + i += 1 + chunk_sent = True + break + # Try smaller chunk + continue + + if not chunk_sent: + # Should not reach here due to single notification handling + logger.error(f"Failed to send notifications starting at index {i}") + failed_indices.append(i) + i += 1 + + # Only empty the batch if ALL notifications were sent successfully + if successfully_sent_count == len(batch_messages): + logger.info( + f"Successfully sent all {successfully_sent_count} notifications, clearing batch" + ) + await get_database_manager_async_client().empty_user_notification_batch( + event.user_id, event.type + ) + else: + logger.warning( + f"Only sent {successfully_sent_count} of {len(batch_messages)} notifications. " + f"Failed indices: {failed_indices}. Batch will be retained for retry." + ) + return True + except Exception as e: + logger.exception(f"Error processing notification for batch queue: {e}") + return False + + async def _process_summary(self, message: str) -> bool: + """Process a single notification with a summary strategy, returning whether to put into the failed queue""" + try: + logger.info(f"Processing summary notification: {message}") + event = BaseEventModel.model_validate_json(message) + model = SummaryParamsEventModel[ + get_summary_params_type(event.type) + ].model_validate_json(message) + + logger.info(f"Processing summary notification: {model}") + + recipient_email = ( + await get_database_manager_async_client().get_user_email_by_id( + event.user_id + ) + ) + if not recipient_email: + logger.error(f"User email not found for user {event.user_id}") + return False + should_send = await self._should_email_user_based_on_preference( + event.user_id, event.type + ) + if not should_send: + logger.info( + f"User {event.user_id} does not want to receive {event.type} notifications" + ) + return True + + summary_data = await self._gather_summary_data( + event.user_id, event.type, model.data + ) + + unsub_link = generate_unsubscribe_link(event.user_id) + + data = NotificationEventModel( + user_id=event.user_id, + type=event.type, + data=summary_data, + ) + + self.email_sender.send_templated( + notification=event.type, + user_email=recipient_email, + data=data, + user_unsub_link=unsub_link, + ) + return True + except Exception as e: + logger.exception(f"Error processing notification for summary queue: {e}") + return False + + async def _consume_queue( + self, + queue: aio_pika.abc.AbstractQueue, + process_func: Callable[[str], Awaitable[bool]], + queue_name: str, + ): + """Continuously consume messages from a queue using async iteration""" + logger.info(f"Starting consumer for queue: {queue_name}") + + try: + async with queue.iterator() as queue_iter: + async for message in queue_iter: + if not self.running: + break + + try: + async with message.process(): + result = await process_func(message.body.decode()) + if not result: + # Message will be rejected when exiting context without exception + raise aio_pika.exceptions.MessageProcessError( + "Processing failed" + ) + except aio_pika.exceptions.MessageProcessError: + # Let message.process() handle the rejection + pass + except Exception as e: + logger.error(f"Error processing message in {queue_name}: {e}") + # Let message.process() handle the rejection + raise + except asyncio.CancelledError: + logger.info(f"Consumer for {queue_name} cancelled") + raise + except Exception as e: + logger.exception(f"Fatal error in consumer for {queue_name}: {e}") + raise + + @continuous_retry() + def run_service(self): + self.run_and_wait(self._run_service()) + + async def _run_service(self): + logger.info(f"[{self.service_name}] ⏳ Configuring RabbitMQ...") + self.rabbitmq_service = rabbitmq.AsyncRabbitMQ(self.rabbitmq_config) + await self.rabbitmq_service.connect() + + logger.info(f"[{self.service_name}] Started notification service") + + # Set up queue consumers with QoS settings + channel = await self.rabbit.get_channel() + + # Set prefetch to prevent overwhelming the service + await channel.set_qos(prefetch_count=10) + + immediate_queue = await channel.get_queue("immediate_notifications") + batch_queue = await channel.get_queue("batch_notifications") + admin_queue = await channel.get_queue("admin_notifications") + summary_queue = await channel.get_queue("summary_notifications") + + # Create consumer tasks for each queue - running in parallel + consumer_tasks = [ + asyncio.create_task( + self._consume_queue( + queue=immediate_queue, + process_func=self._process_immediate, + queue_name="immediate_notifications", + ) + ), + asyncio.create_task( + self._consume_queue( + queue=admin_queue, + process_func=self._process_admin_message, + queue_name="admin_notifications", + ) + ), + asyncio.create_task( + self._consume_queue( + queue=batch_queue, + process_func=self._process_batch, + queue_name="batch_notifications", + ) + ), + asyncio.create_task( + self._consume_queue( + queue=summary_queue, + process_func=self._process_summary, + queue_name="summary_notifications", + ) + ), + ] + + try: + # Run all consumers concurrently + await asyncio.gather(*consumer_tasks) + except asyncio.CancelledError: + logger.info("Service shutdown requested") + # Cancel all consumer tasks + for task in consumer_tasks: + task.cancel() + # Wait for all tasks to complete cancellation + await asyncio.gather(*consumer_tasks, return_exceptions=True) + raise + + def cleanup(self): + """Cleanup service resources""" + self.running = False + super().cleanup() + logger.info(f"[{self.service_name}] ⏳ Disconnecting RabbitMQ...") + self.run_and_wait(self.rabbitmq_service.disconnect()) + + +class NotificationManagerClient(AppServiceClient): + @classmethod + def get_service_type(cls): + return NotificationManager + + process_existing_batches = endpoint_to_sync( + NotificationManager.process_existing_batches + ) + queue_weekly_summary = endpoint_to_sync(NotificationManager.queue_weekly_summary) + discord_system_alert = endpoint_to_sync(NotificationManager.discord_system_alert) diff --git a/autogpt_platform/backend/backend/notifications/templates/agent_approved.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/agent_approved.html.jinja2 new file mode 100644 index 000000000000..14ca59158069 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/agent_approved.html.jinja2 @@ -0,0 +1,73 @@ +{# Agent Approved Notification Email Template #} +{# + Template variables: + data.agent_name: the name of the approved agent + data.agent_id: the ID of the agent + data.agent_version: the version of the agent + data.reviewer_name: the name of the reviewer who approved it + data.reviewer_email: the email of the reviewer + data.comments: comments from the reviewer + data.reviewed_at: when the agent was reviewed + data.store_url: URL to view the agent in the store + + Subject: 🎉 Your agent '{{ data.agent_name }}' has been approved! +#} + +{% block content %} +

+ 🎉 Congratulations! +

+ +

+ Your agent '{{ data.agent_name }}' has been approved and is now live in the store! +

+ +
+ +{% if data.comments %} +
+

+ 💬 Creator feedback area +

+

+ {{ data.comments }} +

+
+ +
+{% endif %} + +
+

+ What's Next? +

+
    +
  • Your agent is now live and discoverable in the AutoGPT Store
  • +
  • Users can find, install, and run your agent
  • +
  • You can update your agent anytime by submitting a new version
  • +
+
+ +
+ + + +
+ +
+

+ 💡 Pro Tip: Share your agent with the community! Post about it on social media, forums, or your blog to help more users discover and benefit from your creation. +

+
+ +
+ +

+ Thank you for contributing to the AutoGPT ecosystem! 🚀 +

+ +{% endblock %} \ No newline at end of file diff --git a/autogpt_platform/backend/backend/notifications/templates/agent_rejected.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/agent_rejected.html.jinja2 new file mode 100644 index 000000000000..69a808190024 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/agent_rejected.html.jinja2 @@ -0,0 +1,77 @@ +{# Agent Rejected Notification Email Template #} +{# + Template variables: + data.agent_name: the name of the rejected agent + data.agent_id: the ID of the agent + data.agent_version: the version of the agent + data.reviewer_name: the name of the reviewer who rejected it + data.reviewer_email: the email of the reviewer + data.comments: comments from the reviewer explaining the rejection + data.reviewed_at: when the agent was reviewed + data.resubmit_url: URL to resubmit the agent + + Subject: Your agent '{{ data.agent_name }}' needs some updates +#} + + +{% block content %} +

+ 📝 Review Complete +

+ +

+ Your agent '{{ data.agent_name }}' needs some updates before approval. +

+ +
+ +
+

+ 💬 Creator feedback area +

+

+ {{ data.comments }} +

+
+ +
+ +
+

+ ☑ Steps to Resubmit: +

+
    +
  • Review the feedback provided above carefully
  • +
  • Make the necessary updates to your agent
  • +
  • Test your agent thoroughly to ensure it works as expected
  • +
  • Submit your updated agent for review
  • +
+
+ +
+ +
+

+ 💡 Tip: Address all the points mentioned in the feedback to increase your chances of approval in the next review. +

+
+ + + +
+

+ 🌟 Don't Give Up! Many successful agents go through multiple iterations before approval. Our review team is here to help you succeed! +

+
+ +
+ +

+ We're excited to see your improved agent submission! 🚀 +

+ +{% endblock %} \ No newline at end of file diff --git a/autogpt_platform/backend/backend/notifications/templates/agent_run.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/agent_run.html.jinja2 new file mode 100644 index 000000000000..043e33aed145 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/agent_run.html.jinja2 @@ -0,0 +1,142 @@ +{# Agent Run #} +{# Template variables: +notification.data: the stuff below but a list of them +data.agent_name: the name of the agent +data.credits_used: the number of credits used by the agent +data.node_count: the number of nodes the agent ran on +data.execution_time: the time it took to run the agent +data.graph_id: the id of the graph the agent ran on +data.outputs: the list of outputs of the agent +#} +{% if notifications is defined %} + {# BATCH MODE #} +
+

Agent Run Summary

+

+ {{ notifications|length }} agent runs have completed! +

+ + {# Calculate summary stats #} + {% set total_time = 0 %} + {% set total_nodes = 0 %} + {% set total_credits = 0 %} + {% set agent_names = [] %} + + {% for notification in notifications %} + {% set total_time = total_time + notification.data.execution_time %} + {% set total_nodes = total_nodes + notification.data.node_count %} + {% set total_credits = total_credits + notification.data.credits_used %} + {% if notification.data.agent_name not in agent_names %} + {% set agent_names = agent_names + [notification.data.agent_name] %} + {% endif %} + {% endfor %} + +
+

Summary

+

Agents: {{ agent_names|join(", ") }}

+

Total Time: {{ total_time | int }} seconds

+

Total Nodes: {{ total_nodes }}

+

Total Cost: ${{ "{:.2f}".format((total_credits|float)/100) }}

+
+ +

Individual Runs

+ + {% for notification in notifications %} +
+

+ Agent: {{ notification.data.agent_name }} +

+ +
+

Time: {{ notification.data.execution_time | int }} seconds

+

Nodes: {{ notification.data.node_count }}

+

Cost: ${{ "{:.2f}".format((notification.data.credits_used|float)/100) }}

+
+ + {% if notification.data.outputs and notification.data.outputs|length > 0 %} +
+

Results:

+ + {% for output in notification.data.outputs %} +
+

+ {{ output.name }} +

+ + {% for key, value in output.items() %} + {% if key != 'name' %} +
+ {% if value is iterable and value is not string %} + {% if value|length == 1 %} + {{ value[0] }} + {% else %} + [{% for item in value %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}] + {% endif %} + {% else %} + {{ value }} + {% endif %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
+ {% endif %} +
+ {% endfor %} +
+ +{% else %} + {# SINGLE NOTIFICATION MODE - Original template #} +

+ Your agent, {{ data.agent_name }}, has completed its run! +

+ +

+

Time Taken: {{ data.execution_time | int }} seconds

+

Nodes Used: {{ data.node_count }}

+

Cost: ${{ "{:.2f}".format((data.credits_used|float)/100) }}

+

+ + {% if data.outputs and data.outputs|length > 0 %} +
+

+ Results: +

+ + {% for output in data.outputs %} +
+

+ {{ output.name }} +

+ + {% for key, value in output.items() %} + {% if key != 'name' %} +
+ {% if value is iterable and value is not string %} + {% if value|length == 1 %} + {{ value[0] }} + {% else %} + [{% for item in value %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}] + {% endif %} + {% else %} + {{ value }} + {% endif %} +
+ {% endif %} + {% endfor %} +
+ {% endfor %} +
+ {% endif %} +{% endif %} diff --git a/autogpt_platform/backend/backend/notifications/templates/base.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/base.html.jinja2 new file mode 100644 index 000000000000..3e5ddf22ff1e --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/base.html.jinja2 @@ -0,0 +1,343 @@ +{# Base Template #} +{# Template variables: + data.message: the message to display in the email + data.title: the title of the email + data.unsubscribe_link: the link to unsubscribe from the email +#} + + + + + + + + + + + + + + + {{data.title}} + + + +
+ + + + + +
+ + + + + +
+ + + + + + + + +
+
+ + + + +
+ +
+
+ + + + + + +
+ {{data.message|safe}} +
+ + + + + + +
+ + + + +
+

+ Thank you for being a part of the AutoGPT community! Join the conversation on our Discord here and share your thoughts with us anytime. +

+
+
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + + +
+ + x + + + + discord + + + + website + +
+
+
+
+ AutoGPT +
+
+

+ 3rd Floor 1 Ashley Road, Cheshire, United Kingdom, WA14 2DT, Altrincham
United Kingdom +

+
+

+ You received this email because you signed up on our website.

+
+

+ Unsubscribe +

+
+
+
+
+
+ + + \ No newline at end of file diff --git a/autogpt_platform/backend/backend/notifications/templates/low_balance.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/low_balance.html.jinja2 new file mode 100644 index 000000000000..8be13d047350 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/low_balance.html.jinja2 @@ -0,0 +1,103 @@ +{# Low Balance Notification Email Template #} +{# Template variables: +data.current_balance: the current balance of the user +data.billing_page_link: the link to the billing page +#} + +

+ Low Balance Warning +

+ +

+ Your account balance has dropped below the recommended threshold. +

+ +
+

+ Current Balance: ${{ "{:.2f}".format((data.current_balance|float)/100) }} +

+
+ + +
+

+ Low Balance: +

+

+ Your account requires additional credits to continue running agents. Please add credits to your account to avoid service interruption. +

+
+ + + +

+ This is an automated low balance notification. Consider adding credits soon to avoid service interruption. +

diff --git a/autogpt_platform/backend/backend/notifications/templates/refund_processed.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/refund_processed.html.jinja2 new file mode 100644 index 000000000000..244c062a7059 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/refund_processed.html.jinja2 @@ -0,0 +1,51 @@ +{# Refund Processed Notification Email Template #} +{# + Template variables: + data.user_id: the ID of the user + data.user_name: the user's name + data.user_email: the user's email address + data.transaction_id: the transaction ID for the refund request + data.refund_request_id: the refund request ID + data.reason: the reason for the refund request + data.amount: the refund amount in cents (divide by 100 for dollars) + data.balance: the user's latest balance in cents (after the refund deduction) + + Subject: Refund for ${{ data.amount / 100 }} to {{ data.user_name }} has been processed +#} + + + + + + Refund Processed Notification + + +

Hello Administrator,

+ +

+ This is to notify you that the refund for ${{ data.amount / 100 }} to {{ data.user_name }} has been processed successfully. +

+ +

Refund Details

+
    +
  • User ID: {{ data.user_id }}
  • +
  • User Name: {{ data.user_name }}
  • +
  • User Email: {{ data.user_email }}
  • +
  • Transaction ID: {{ data.transaction_id }}
  • +
  • Refund Request ID: {{ data.refund_request_id }}
  • +
  • Refund Amount: ${{ data.amount / 100 }}
  • +
  • Reason for Refund: {{ data.reason }}
  • +
  • Latest User Balance: ${{ data.balance / 100 }}
  • +
+ +

+ The user's balance has been updated accordingly after the deduction. +

+ +

+ Please contact the support team if you have any questions or need further assistance regarding this refund. +

+ +

Best regards,
Your Notification System

+ + diff --git a/autogpt_platform/backend/backend/notifications/templates/refund_request.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/refund_request.html.jinja2 new file mode 100644 index 000000000000..b9e2ac954ae0 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/refund_request.html.jinja2 @@ -0,0 +1,72 @@ +{# Refund Request Email Template #} +{# + Template variables: + data.user_id: the ID of the user + data.user_name: the user's name + data.user_email: the user's email address + data.transaction_id: the transaction ID for the refund request + data.refund_request_id: the refund request ID + data.reason: the reason for the refund request + data.amount: the refund amount in cents (divide by 100 for dollars) + data.balance: the user's balance in cents (divide by 100 for dollars) + + Subject: [ACTION REQUIRED] You got a ${{ data.amount / 100 }} refund request from {{ data.user_name }} +#} + + + + + + Refund Request Approval Needed + + +

Hello Administrator,

+

+ A refund request has been submitted by a user and requires your approval. +

+ +

Refund Request Details

+
    +
  • User ID: {{ data.user_id }}
  • +
  • User Name: {{ data.user_name }}
  • +
  • User Email: {{ data.user_email }}
  • +
  • Transaction ID: {{ data.transaction_id }}
  • +
  • Refund Request ID: {{ data.refund_request_id }}
  • +
  • Refund Amount: ${{ data.amount / 100 }}
  • +
  • User Balance: ${{ data.balance / 100 }}
  • +
  • Reason for Refund: {{ data.reason }}
  • +
+ +

+ To approve this refund, please click on the following Stripe link: + https://dashboard.stripe.com/test/payments/{{data.transaction_id}} +
+ And then click on the "Refund" button. +

+ +

+ To reject this refund, please follow these steps: +

+
    +
  1. + Visit the Supabase Dashboard: + https://supabase.com/dashboard/project/bgwpwdsxblryihinutbx/editor +
  2. +
  3. + Navigate to the RefundRequest table. +
  4. +
  5. + Filter the transactionKey column with the Transaction ID: {{ data.transaction_id }}. +
  6. +
  7. + Update the status field to REJECTED and enter the rejection reason in the result column. +
  8. +
+ +

+ Please take the necessary action at your earliest convenience. +

+

Thank you for your prompt attention.

+

Best regards,
Your Notification System

+ + diff --git a/autogpt_platform/backend/backend/notifications/templates/weekly_summary.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/weekly_summary.html.jinja2 new file mode 100644 index 000000000000..6de381a94677 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/weekly_summary.html.jinja2 @@ -0,0 +1,68 @@ +{# Weekly Summary #} +{# Template variables: +data: the stuff below +data.start_date: the start date of the summary +data.end_date: the end date of the summary +data.total_credits_used: the total credits used during the summary +data.total_executions: the total number of executions during the summary +data.most_used_agent: the most used agent's name during the summary +data.total_execution_time: the total execution time during the summary +data.successful_runs: the total number of successful runs during the summary +data.failed_runs: the total number of failed runs during the summary +data.average_execution_time: the average execution time during the summary +data.cost_breakdown: the cost breakdown during the summary (dict mapping agent names to credit amounts) +#} + +

+ Weekly Summary +

+ +

+ Your Agent Activity: {{ data.start_date.strftime('%B %-d') }} – {{ data.end_date.strftime('%B %-d') }} +

+ +
+
    +
  • + Total Executions: {{ data.total_executions }} +
  • +
  • + Total Credits Used: {{ data.total_credits_used|format("%.2f") }} +
  • +
  • + Total Execution Time: {{ data.total_execution_time|format("%.1f") }} seconds +
  • +
  • + Successful Runs: {{ data.successful_runs }} +
  • +
  • + Failed Runs: {{ data.failed_runs }} +
  • +
  • + Average Execution Time: {{ data.average_execution_time|format("%.1f") }} seconds +
  • +
  • + Most Used Agent: {{ data.most_used_agent }} +
  • + {% if data.cost_breakdown %} +
  • + Cost Breakdown: +
      + {% for agent_name, credits in data.cost_breakdown.items() %} +
    • + {{ agent_name }}: {{ credits|format("%.2f") }} credits +
    • + {% endfor %} +
    +
  • + {% endif %} +
+
+ +

+ Thank you for being a part of the AutoGPT community! 🎉 +

+ +

+ Join the conversation on Discord here. +

\ No newline at end of file diff --git a/autogpt_platform/backend/backend/notifications/templates/zero_balance.html.jinja2 b/autogpt_platform/backend/backend/notifications/templates/zero_balance.html.jinja2 new file mode 100644 index 000000000000..eb6879b89802 --- /dev/null +++ b/autogpt_platform/backend/backend/notifications/templates/zero_balance.html.jinja2 @@ -0,0 +1,114 @@ +{# Low Balance Notification Email Template #} +{# Template variables: +data.agent_name: the name of the agent +data.current_balance: the current balance of the user +data.billing_page_link: the link to the billing page +data.shortfall: the shortfall amount +#} + +

+ Zero Balance Warning +

+ +

+ Your agent "{{ data.agent_name }}" has been stopped due to low balance. +

+ +
+

+ Current Balance: ${{ "{:.2f}".format((data.current_balance|float)/100) }} +

+

+ Shortfall: ${{ "{:.2f}".format((data.shortfall|float)/100) }} +

+
+ + +
+

+ Low Balance: +

+

+ Your agent "{{ data.agent_name }}" requires additional credits to continue running. The current operation has been canceled until your balance is replenished. +

+
+ + + +

+ This is an automated notification. Your agent is stopped and will need manually restarted unless set to trigger automatically. +

diff --git a/autogpt_platform/backend/backend/rest.py b/autogpt_platform/backend/backend/rest.py index e0da452ca2be..b601144c6f40 100644 --- a/autogpt_platform/backend/backend/rest.py +++ b/autogpt_platform/backend/backend/rest.py @@ -1,5 +1,4 @@ from backend.app import run_processes -from backend.executor import DatabaseManager, ExecutionScheduler from backend.server.rest_api import AgentServer @@ -7,11 +6,7 @@ def main(): """ Run all the processes required for the AutoGPT-server REST API. """ - run_processes( - DatabaseManager(), - ExecutionScheduler(), - AgentServer(), - ) + run_processes(AgentServer()) if __name__ == "__main__": diff --git a/autogpt_platform/backend/backend/scheduler.py b/autogpt_platform/backend/backend/scheduler.py new file mode 100644 index 000000000000..c22faf31afe5 --- /dev/null +++ b/autogpt_platform/backend/backend/scheduler.py @@ -0,0 +1,15 @@ +from backend.app import run_processes +from backend.executor.scheduler import Scheduler + + +def main(): + """ + Run all the processes required for the AutoGPT-server Scheduling System. + """ + run_processes( + Scheduler(), + ) + + +if __name__ == "__main__": + main() diff --git a/autogpt_platform/backend/backend/sdk/__init__.py b/autogpt_platform/backend/backend/sdk/__init__.py new file mode 100644 index 000000000000..666ad08835ac --- /dev/null +++ b/autogpt_platform/backend/backend/sdk/__init__.py @@ -0,0 +1,170 @@ +""" +AutoGPT Platform Block Development SDK + +Complete re-export of all dependencies needed for block development. +Usage: from backend.sdk import * + +This module provides: +- All block base classes and types +- All credential and authentication components +- All cost tracking components +- All webhook components +- All utility functions +- Auto-registration decorators +""" + +# Third-party imports +from pydantic import BaseModel, Field, SecretStr + +# === CORE BLOCK SYSTEM === +from backend.data.block import ( + Block, + BlockCategory, + BlockManualWebhookConfig, + BlockOutput, + BlockSchema, + BlockType, + BlockWebhookConfig, +) +from backend.data.integrations import Webhook, update_webhook +from backend.data.model import APIKeyCredentials, Credentials, CredentialsField +from backend.data.model import CredentialsMetaInput as _CredentialsMetaInput +from backend.data.model import ( + NodeExecutionStats, + OAuth2Credentials, + SchemaField, + UserPasswordCredentials, +) + +# === INTEGRATIONS === +from backend.integrations.providers import ProviderName +from backend.sdk.builder import ProviderBuilder +from backend.sdk.cost_integration import cost +from backend.sdk.provider import Provider + +# === NEW SDK COMPONENTS (imported early for patches) === +from backend.sdk.registry import AutoRegistry, BlockConfiguration + +# === UTILITIES === +from backend.util import json +from backend.util.request import Requests + +# === OPTIONAL IMPORTS WITH TRY/EXCEPT === +# Webhooks +try: + from backend.integrations.webhooks._base import BaseWebhooksManager +except ImportError: + BaseWebhooksManager = None + +try: + from backend.integrations.webhooks._manual_base import ManualWebhookManagerBase +except ImportError: + ManualWebhookManagerBase = None + +# Cost System +try: + from backend.data.cost import BlockCost, BlockCostType +except ImportError: + from backend.data.block_cost_config import BlockCost, BlockCostType + +try: + from backend.data.credit import UsageTransactionMetadata +except ImportError: + UsageTransactionMetadata = None + +try: + from backend.executor.utils import block_usage_cost +except ImportError: + block_usage_cost = None + +# Utilities +try: + from backend.util.file import store_media_file +except ImportError: + store_media_file = None + +try: + from backend.util.type import MediaFileType, convert +except ImportError: + MediaFileType = None + convert = None + +try: + from backend.util.text import TextFormatter +except ImportError: + TextFormatter = None + +try: + from backend.util.logging import TruncatedLogger +except ImportError: + TruncatedLogger = None + + +# OAuth handlers +try: + from backend.integrations.oauth.base import BaseOAuthHandler +except ImportError: + BaseOAuthHandler = None + + +# Credential type with proper provider name +from typing import Literal as _Literal + +CredentialsMetaInput = _CredentialsMetaInput[ + ProviderName, _Literal["api_key", "oauth2", "user_password"] +] + + +# === COMPREHENSIVE __all__ EXPORT === +__all__ = [ + # Core Block System + "Block", + "BlockCategory", + "BlockOutput", + "BlockSchema", + "BlockType", + "BlockWebhookConfig", + "BlockManualWebhookConfig", + # Schema and Model Components + "SchemaField", + "Credentials", + "CredentialsField", + "CredentialsMetaInput", + "APIKeyCredentials", + "OAuth2Credentials", + "UserPasswordCredentials", + "NodeExecutionStats", + # Cost System + "BlockCost", + "BlockCostType", + "UsageTransactionMetadata", + "block_usage_cost", + # Integrations + "ProviderName", + "BaseWebhooksManager", + "ManualWebhookManagerBase", + "Webhook", + "update_webhook", + # Provider-Specific (when available) + "BaseOAuthHandler", + # Utilities + "json", + "store_media_file", + "MediaFileType", + "convert", + "TextFormatter", + "TruncatedLogger", + "BaseModel", + "Field", + "SecretStr", + "Requests", + # SDK Components + "AutoRegistry", + "BlockConfiguration", + "Provider", + "ProviderBuilder", + "cost", +] + +# Remove None values from __all__ +__all__ = [name for name in __all__ if globals().get(name) is not None] diff --git a/autogpt_platform/backend/backend/sdk/builder.py b/autogpt_platform/backend/backend/sdk/builder.py new file mode 100644 index 000000000000..a015560d7345 --- /dev/null +++ b/autogpt_platform/backend/backend/sdk/builder.py @@ -0,0 +1,182 @@ +""" +Builder class for creating provider configurations with a fluent API. +""" + +import logging +import os +from typing import Callable, List, Optional, Type + +from pydantic import SecretStr + +from backend.data.cost import BlockCost, BlockCostType +from backend.data.model import ( + APIKeyCredentials, + Credentials, + CredentialsType, + UserPasswordCredentials, +) +from backend.integrations.oauth.base import BaseOAuthHandler +from backend.integrations.webhooks._base import BaseWebhooksManager +from backend.sdk.provider import OAuthConfig, Provider +from backend.sdk.registry import AutoRegistry +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) + + +class ProviderBuilder: + """Builder for creating provider configurations.""" + + def __init__(self, name: str): + self.name = name + self._oauth_config: Optional[OAuthConfig] = None + self._webhook_manager: Optional[Type[BaseWebhooksManager]] = None + self._default_credentials: List[Credentials] = [] + self._base_costs: List[BlockCost] = [] + self._supported_auth_types: set[CredentialsType] = set() + self._api_client_factory: Optional[Callable] = None + self._error_handler: Optional[Callable[[Exception], str]] = None + self._default_scopes: Optional[List[str]] = None + self._client_id_env_var: Optional[str] = None + self._client_secret_env_var: Optional[str] = None + self._extra_config: dict = {} + + def with_oauth( + self, + handler_class: Type[BaseOAuthHandler], + scopes: Optional[List[str]] = None, + client_id_env_var: Optional[str] = None, + client_secret_env_var: Optional[str] = None, + ) -> "ProviderBuilder": + """Add OAuth support.""" + if not client_id_env_var or not client_secret_env_var: + client_id_env_var = f"{self.name}_client_id".upper() + client_secret_env_var = f"{self.name}_client_secret".upper() + + if os.getenv(client_id_env_var) and os.getenv(client_secret_env_var): + self._client_id_env_var = client_id_env_var + self._client_secret_env_var = client_secret_env_var + + self._oauth_config = OAuthConfig( + oauth_handler=handler_class, + scopes=scopes, + client_id_env_var=client_id_env_var, + client_secret_env_var=client_secret_env_var, + ) + self._supported_auth_types.add("oauth2") + else: + logger.warning( + f"Provider {self.name.upper()} implements OAuth but the required env " + f"vars {client_id_env_var} and {client_secret_env_var} are not both set" + ) + return self + + def with_api_key(self, env_var_name: str, title: str) -> "ProviderBuilder": + """Add API key support with environment variable name.""" + self._supported_auth_types.add("api_key") + + # Register the API key mapping + AutoRegistry.register_api_key(self.name, env_var_name) + + # Check if API key exists in environment + api_key = os.getenv(env_var_name) + if api_key: + self._default_credentials.append( + APIKeyCredentials( + id=f"{self.name}-default", + provider=self.name, + api_key=SecretStr(api_key), + title=title, + ) + ) + return self + + def with_api_key_from_settings( + self, settings_attr: str, title: str + ) -> "ProviderBuilder": + """Use existing API key from settings.""" + self._supported_auth_types.add("api_key") + + # Try to get the API key from settings + settings = Settings() + api_key = getattr(settings.secrets, settings_attr, None) + if api_key: + self._default_credentials.append( + APIKeyCredentials( + id=f"{self.name}-default", + provider=self.name, + api_key=api_key, + title=title, + ) + ) + return self + + def with_user_password( + self, username_env_var: str, password_env_var: str, title: str + ) -> "ProviderBuilder": + """Add username/password support with environment variable names.""" + self._supported_auth_types.add("user_password") + + # Check if credentials exist in environment + username = os.getenv(username_env_var) + password = os.getenv(password_env_var) + if username and password: + self._default_credentials.append( + UserPasswordCredentials( + id=f"{self.name}-default", + provider=self.name, + username=SecretStr(username), + password=SecretStr(password), + title=title, + ) + ) + return self + + def with_webhook_manager( + self, manager_class: Type[BaseWebhooksManager] + ) -> "ProviderBuilder": + """Register webhook manager for this provider.""" + self._webhook_manager = manager_class + return self + + def with_base_cost( + self, amount: int, cost_type: BlockCostType + ) -> "ProviderBuilder": + """Set base cost for all blocks using this provider.""" + self._base_costs.append(BlockCost(cost_amount=amount, cost_type=cost_type)) + return self + + def with_api_client(self, factory: Callable) -> "ProviderBuilder": + """Register API client factory.""" + self._api_client_factory = factory + return self + + def with_error_handler( + self, handler: Callable[[Exception], str] + ) -> "ProviderBuilder": + """Register error handler for provider-specific errors.""" + self._error_handler = handler + return self + + def with_config(self, **kwargs) -> "ProviderBuilder": + """Add additional configuration options.""" + self._extra_config.update(kwargs) + return self + + def build(self) -> Provider: + """Build and register the provider configuration.""" + provider = Provider( + name=self.name, + oauth_config=self._oauth_config, + webhook_manager=self._webhook_manager, + default_credentials=self._default_credentials, + base_costs=self._base_costs, + supported_auth_types=self._supported_auth_types, + api_client_factory=self._api_client_factory, + error_handler=self._error_handler, + **self._extra_config, + ) + + # Auto-registration happens here + AutoRegistry.register_provider(provider) + return provider diff --git a/autogpt_platform/backend/backend/sdk/cost_integration.py b/autogpt_platform/backend/backend/sdk/cost_integration.py new file mode 100644 index 000000000000..3eb4b470627e --- /dev/null +++ b/autogpt_platform/backend/backend/sdk/cost_integration.py @@ -0,0 +1,163 @@ +""" +Integration between SDK provider costs and the execution cost system. + +This module provides the glue between provider-defined base costs and the +BLOCK_COSTS configuration used by the execution system. +""" + +import logging +from typing import List, Type + +from backend.data.block import Block +from backend.data.block_cost_config import BLOCK_COSTS +from backend.data.cost import BlockCost +from backend.sdk.registry import AutoRegistry + +logger = logging.getLogger(__name__) + + +def register_provider_costs_for_block(block_class: Type[Block]) -> None: + """ + Register provider base costs for a specific block in BLOCK_COSTS. + + This function checks if the block uses credentials from a provider that has + base costs defined, and automatically registers those costs for the block. + + Args: + block_class: The block class to register costs for + """ + # Skip if block already has custom costs defined + if block_class in BLOCK_COSTS: + logger.debug( + f"Block {block_class.__name__} already has costs defined, skipping provider costs" + ) + return + + # Get the block's input schema + # We need to instantiate the block to get its input schema + try: + block_instance = block_class() + input_schema = block_instance.input_schema + except Exception as e: + logger.debug(f"Block {block_class.__name__} cannot be instantiated: {e}") + return + + # Look for credentials fields + # The cost system works of filtering on credentials fields, + # without credentials fields, we can not apply costs + # TODO: Improve cost system to allow for costs witout a provider + credentials_fields = input_schema.get_credentials_fields() + if not credentials_fields: + logger.debug(f"Block {block_class.__name__} has no credentials fields") + return + + # Get provider information from credentials fields + for field_name, field_info in credentials_fields.items(): + # Get the field schema to extract provider information + field_schema = input_schema.get_field_schema(field_name) + + # Extract provider names from json_schema_extra + providers = field_schema.get("credentials_provider", []) + if not providers: + continue + + # For each provider, check if it has base costs + block_costs: List[BlockCost] = [] + for provider_name in providers: + provider = AutoRegistry.get_provider(provider_name) + if not provider: + logger.debug(f"Provider {provider_name} not found in registry") + continue + + # Add provider's base costs to the block + if provider.base_costs: + logger.debug( + f"Registering {len(provider.base_costs)} base costs from provider {provider_name} for block {block_class.__name__}" + ) + block_costs.extend(provider.base_costs) + + # Register costs if any were found + if block_costs: + BLOCK_COSTS[block_class] = block_costs + logger.debug( + f"Registered {len(block_costs)} total costs for block {block_class.__name__}" + ) + + +def sync_all_provider_costs() -> None: + """ + Sync all provider base costs to blocks that use them. + + This should be called after all providers and blocks are registered, + typically during application startup. + """ + from backend.blocks import load_all_blocks + + logger.info("Syncing provider costs to blocks...") + + blocks_with_costs = 0 + total_costs = 0 + + for block_id, block_class in load_all_blocks().items(): + initial_count = len(BLOCK_COSTS.get(block_class, [])) + register_provider_costs_for_block(block_class) + final_count = len(BLOCK_COSTS.get(block_class, [])) + + if final_count > initial_count: + blocks_with_costs += 1 + total_costs += final_count - initial_count + + logger.info(f"Synced {total_costs} costs to {blocks_with_costs} blocks") + + +def get_block_costs(block_class: Type[Block]) -> List[BlockCost]: + """ + Get all costs for a block, including both explicit and provider costs. + + Args: + block_class: The block class to get costs for + + Returns: + List of BlockCost objects for the block + """ + # First ensure provider costs are registered + register_provider_costs_for_block(block_class) + + # Return all costs for the block + return BLOCK_COSTS.get(block_class, []) + + +def cost(*costs: BlockCost): + """ + Decorator to set custom costs for a block. + + This decorator allows blocks to define their own costs, which will override + any provider base costs. Multiple costs can be specified with different + filters for different pricing tiers (e.g., different models). + + Example: + @cost( + BlockCost(cost_type=BlockCostType.RUN, cost_amount=10), + BlockCost( + cost_type=BlockCostType.RUN, + cost_amount=20, + cost_filter={"model": "premium"} + ) + ) + class MyBlock(Block): + ... + + Args: + *costs: Variable number of BlockCost objects + """ + + def decorator(block_class: Type[Block]) -> Type[Block]: + # Register the costs for this block + if costs: + BLOCK_COSTS[block_class] = list(costs) + logger.info( + f"Registered {len(costs)} custom costs for block {block_class.__name__}" + ) + return block_class + + return decorator diff --git a/autogpt_platform/backend/backend/sdk/provider.py b/autogpt_platform/backend/backend/sdk/provider.py new file mode 100644 index 000000000000..83a24ab0219f --- /dev/null +++ b/autogpt_platform/backend/backend/sdk/provider.py @@ -0,0 +1,157 @@ +""" +Provider configuration class that holds all provider-related settings. +""" + +import uuid +from typing import Any, Callable, List, Optional, Set, Type + +from pydantic import BaseModel, SecretStr + +from backend.data.cost import BlockCost +from backend.data.model import ( + APIKeyCredentials, + Credentials, + CredentialsField, + CredentialsMetaInput, + CredentialsType, + OAuth2Credentials, + UserPasswordCredentials, +) +from backend.integrations.oauth.base import BaseOAuthHandler +from backend.integrations.webhooks._base import BaseWebhooksManager + + +class OAuthConfig(BaseModel): + """Configuration for OAuth authentication.""" + + oauth_handler: Type[BaseOAuthHandler] + scopes: Optional[List[str]] = None + client_id_env_var: str + client_secret_env_var: str + + +class Provider: + """A configured provider that blocks can use. + + A Provider represents a service or platform that blocks can integrate with, like Linear, OpenAI, etc. + It contains configuration for: + - Authentication (OAuth, API keys) + - Default credentials + - Base costs for using the provider + - Webhook handling + - Error handling + - API client factory + + Blocks use Provider instances to handle authentication, make API calls, and manage service-specific logic. + """ + + def __init__( + self, + name: str, + oauth_config: Optional[OAuthConfig] = None, + webhook_manager: Optional[Type[BaseWebhooksManager]] = None, + default_credentials: Optional[List[Credentials]] = None, + base_costs: Optional[List[BlockCost]] = None, + supported_auth_types: Optional[Set[CredentialsType]] = None, + api_client_factory: Optional[Callable] = None, + error_handler: Optional[Callable[[Exception], str]] = None, + **kwargs, + ): + self.name = name + self.oauth_config = oauth_config + self.webhook_manager = webhook_manager + self.default_credentials = default_credentials or [] + self.base_costs = base_costs or [] + self.supported_auth_types = supported_auth_types or set() + self._api_client_factory = api_client_factory + self._error_handler = error_handler + + # Store any additional configuration + self._extra_config = kwargs + self.test_credentials_uuid = uuid.uuid4() + + def credentials_field(self, **kwargs) -> CredentialsMetaInput: + """Return a CredentialsField configured for this provider.""" + # Extract known CredentialsField parameters + title = kwargs.pop("title", None) + description = kwargs.pop("description", f"{self.name.title()} credentials") + required_scopes = kwargs.pop("required_scopes", set()) + discriminator = kwargs.pop("discriminator", None) + discriminator_mapping = kwargs.pop("discriminator_mapping", None) + discriminator_values = kwargs.pop("discriminator_values", None) + + # Create json_schema_extra with provider information + json_schema_extra = { + "credentials_provider": [self.name], + "credentials_types": ( + list(self.supported_auth_types) if self.supported_auth_types else [] + ), + } + + # Merge any existing json_schema_extra + if "json_schema_extra" in kwargs: + json_schema_extra.update(kwargs.pop("json_schema_extra")) + + # Add json_schema_extra to kwargs + kwargs["json_schema_extra"] = json_schema_extra + + return CredentialsField( + required_scopes=required_scopes, + discriminator=discriminator, + discriminator_mapping=discriminator_mapping, + discriminator_values=discriminator_values, + title=title, + description=description, + **kwargs, + ) + + def get_test_credentials(self) -> Credentials: + """Get test credentials for the provider based on supported auth types.""" + test_id = str(self.test_credentials_uuid) + + # Return credentials based on the first supported auth type + if "user_password" in self.supported_auth_types: + return UserPasswordCredentials( + id=test_id, + provider=self.name, + username=SecretStr(f"mock-{self.name}-username"), + password=SecretStr(f"mock-{self.name}-password"), + title=f"Mock {self.name.title()} credentials", + ) + elif "oauth2" in self.supported_auth_types: + return OAuth2Credentials( + id=test_id, + provider=self.name, + username=f"mock-{self.name}-username", + access_token=SecretStr(f"mock-{self.name}-access-token"), + access_token_expires_at=None, + refresh_token=SecretStr(f"mock-{self.name}-refresh-token"), + refresh_token_expires_at=None, + scopes=[f"mock-{self.name}-scope"], + title=f"Mock {self.name.title()} OAuth credentials", + ) + else: + # Default to API key credentials + return APIKeyCredentials( + id=test_id, + provider=self.name, + api_key=SecretStr(f"mock-{self.name}-api-key"), + title=f"Mock {self.name.title()} API key", + expires_at=None, + ) + + def get_api(self, credentials: Credentials) -> Any: + """Get API client instance for the given credentials.""" + if self._api_client_factory: + return self._api_client_factory(credentials) + raise NotImplementedError(f"No API client factory registered for {self.name}") + + def handle_error(self, error: Exception) -> str: + """Handle provider-specific errors.""" + if self._error_handler: + return self._error_handler(error) + return str(error) + + def get_config(self, key: str, default: Any = None) -> Any: + """Get additional configuration value.""" + return self._extra_config.get(key, default) diff --git a/autogpt_platform/backend/backend/sdk/registry.py b/autogpt_platform/backend/backend/sdk/registry.py new file mode 100644 index 000000000000..888f85794989 --- /dev/null +++ b/autogpt_platform/backend/backend/sdk/registry.py @@ -0,0 +1,212 @@ +""" +Auto-registration system for blocks, providers, and their configurations. +""" + +import logging +import threading +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Type + +from pydantic import BaseModel, SecretStr + +from backend.blocks.basic import Block +from backend.data.model import APIKeyCredentials, Credentials +from backend.integrations.oauth.base import BaseOAuthHandler +from backend.integrations.providers import ProviderName +from backend.integrations.webhooks._base import BaseWebhooksManager + +if TYPE_CHECKING: + from backend.sdk.provider import Provider + + +class SDKOAuthCredentials(BaseModel): + """OAuth credentials configuration for SDK providers.""" + + use_secrets: bool = False + client_id_env_var: Optional[str] = None + client_secret_env_var: Optional[str] = None + + +class BlockConfiguration: + """Configuration associated with a block.""" + + def __init__( + self, + provider: str, + costs: List[Any], + default_credentials: List[Credentials], + webhook_manager: Optional[Type[BaseWebhooksManager]] = None, + oauth_handler: Optional[Type[BaseOAuthHandler]] = None, + ): + self.provider = provider + self.costs = costs + self.default_credentials = default_credentials + self.webhook_manager = webhook_manager + self.oauth_handler = oauth_handler + + +class AutoRegistry: + """Central registry for all block-related configurations.""" + + _lock = threading.Lock() + _providers: Dict[str, "Provider"] = {} + _default_credentials: List[Credentials] = [] + _oauth_handlers: Dict[str, Type[BaseOAuthHandler]] = {} + _oauth_credentials: Dict[str, SDKOAuthCredentials] = {} + _webhook_managers: Dict[str, Type[BaseWebhooksManager]] = {} + _block_configurations: Dict[Type[Block], BlockConfiguration] = {} + _api_key_mappings: Dict[str, str] = {} # provider -> env_var_name + + @classmethod + def register_provider(cls, provider: "Provider") -> None: + """Auto-register provider and all its configurations.""" + with cls._lock: + cls._providers[provider.name] = provider + + # Register OAuth handler if provided + if provider.oauth_config: + # Dynamically set PROVIDER_NAME if not already set + if ( + not hasattr(provider.oauth_config.oauth_handler, "PROVIDER_NAME") + or provider.oauth_config.oauth_handler.PROVIDER_NAME is None + ): + # This works because ProviderName has _missing_ method + provider.oauth_config.oauth_handler.PROVIDER_NAME = ProviderName( + provider.name + ) + cls._oauth_handlers[provider.name] = provider.oauth_config.oauth_handler + + oauth_creds = SDKOAuthCredentials( + use_secrets=False, # SDK providers use custom env vars + client_id_env_var=provider.oauth_config.client_id_env_var, + client_secret_env_var=provider.oauth_config.client_secret_env_var, + ) + cls._oauth_credentials[provider.name] = oauth_creds + + # Register webhook manager if provided + if provider.webhook_manager: + # Dynamically set PROVIDER_NAME if not already set + if ( + not hasattr(provider.webhook_manager, "PROVIDER_NAME") + or provider.webhook_manager.PROVIDER_NAME is None + ): + + # This works because ProviderName has _missing_ method + provider.webhook_manager.PROVIDER_NAME = ProviderName(provider.name) + cls._webhook_managers[provider.name] = provider.webhook_manager + + # Register default credentials + cls._default_credentials.extend(provider.default_credentials) + + @classmethod + def register_api_key(cls, provider: str, env_var_name: str) -> None: + """Register an environment variable as an API key for a provider.""" + with cls._lock: + cls._api_key_mappings[provider] = env_var_name + + # Dynamically check if the env var exists and create credential + import os + + api_key = os.getenv(env_var_name) + if api_key: + credential = APIKeyCredentials( + id=f"{provider}-default", + provider=provider, + api_key=SecretStr(api_key), + title=f"Default {provider} credentials", + ) + # Check if credential already exists to avoid duplicates + if not any(c.id == credential.id for c in cls._default_credentials): + cls._default_credentials.append(credential) + + @classmethod + def get_all_credentials(cls) -> List[Credentials]: + """Replace hardcoded get_all_creds() in credentials_store.py.""" + with cls._lock: + return cls._default_credentials.copy() + + @classmethod + def get_oauth_handlers(cls) -> Dict[str, Type[BaseOAuthHandler]]: + """Replace HANDLERS_BY_NAME in oauth/__init__.py.""" + with cls._lock: + return cls._oauth_handlers.copy() + + @classmethod + def get_oauth_credentials(cls) -> Dict[str, SDKOAuthCredentials]: + """Get OAuth credentials configuration for SDK providers.""" + with cls._lock: + return cls._oauth_credentials.copy() + + @classmethod + def get_webhook_managers(cls) -> Dict[str, Type[BaseWebhooksManager]]: + """Replace load_webhook_managers() in webhooks/__init__.py.""" + with cls._lock: + return cls._webhook_managers.copy() + + @classmethod + def register_block_configuration( + cls, block_class: Type[Block], config: BlockConfiguration + ) -> None: + """Register configuration for a specific block class.""" + with cls._lock: + cls._block_configurations[block_class] = config + + @classmethod + def get_provider(cls, name: str) -> Optional["Provider"]: + """Get a registered provider by name.""" + with cls._lock: + return cls._providers.get(name) + + @classmethod + def get_all_provider_names(cls) -> List[str]: + """Get all registered provider names.""" + with cls._lock: + return list(cls._providers.keys()) + + @classmethod + def clear(cls) -> None: + """Clear all registrations (useful for testing).""" + with cls._lock: + cls._providers.clear() + cls._default_credentials.clear() + cls._oauth_handlers.clear() + cls._webhook_managers.clear() + cls._block_configurations.clear() + cls._api_key_mappings.clear() + + @classmethod + def patch_integrations(cls) -> None: + """Patch existing integration points to use AutoRegistry.""" + # OAuth handlers are handled by SDKAwareHandlersDict in oauth/__init__.py + # No patching needed for OAuth handlers + + # Patch webhook managers + try: + import sys + from typing import Any + + # Get the module from sys.modules to respect mocking + if "backend.integrations.webhooks" in sys.modules: + webhooks: Any = sys.modules["backend.integrations.webhooks"] + else: + import backend.integrations.webhooks + + webhooks: Any = backend.integrations.webhooks + + if hasattr(webhooks, "load_webhook_managers"): + original_load = webhooks.load_webhook_managers + + def patched_load(): + # Get original managers + managers = original_load() + # Add SDK-registered managers + sdk_managers = cls.get_webhook_managers() + if isinstance(sdk_managers, dict): + # Convert string keys to ProviderName for consistency + for provider_str, manager in sdk_managers.items(): + provider_name = ProviderName(provider_str) + managers[provider_name] = manager + return managers + + webhooks.load_webhook_managers = patched_load + except Exception as e: + logging.warning(f"Failed to patch webhook managers: {e}") diff --git a/autogpt_platform/backend/backend/server/conftest.py b/autogpt_platform/backend/backend/server/conftest.py new file mode 100644 index 000000000000..8672436296ae --- /dev/null +++ b/autogpt_platform/backend/backend/server/conftest.py @@ -0,0 +1,17 @@ +"""Common test fixtures for server tests.""" + +import pytest +from pytest_snapshot.plugin import Snapshot + + +@pytest.fixture +def configured_snapshot(snapshot: Snapshot) -> Snapshot: + """Pre-configured snapshot fixture with standard settings.""" + snapshot.snapshot_dir = "snapshots" + return snapshot + + +# Test ID constants +TEST_USER_ID = "test-user-id" +ADMIN_USER_ID = "admin-user-id" +TARGET_USER_ID = "target-user-id" diff --git a/autogpt_platform/backend/backend/server/conn_manager.py b/autogpt_platform/backend/backend/server/conn_manager.py index 6a4fb8a57860..043002861017 100644 --- a/autogpt_platform/backend/backend/server/conn_manager.py +++ b/autogpt_platform/backend/backend/server/conn_manager.py @@ -2,8 +2,17 @@ from fastapi import WebSocket -from backend.data import execution -from backend.server.model import Methods, WsMessage +from backend.data.execution import ( + ExecutionEventType, + GraphExecutionEvent, + NodeExecutionEvent, +) +from backend.server.model import WSMessage, WSMethod + +_EVENT_TYPE_TO_METHOD_MAP: dict[ExecutionEventType, WSMethod] = { + ExecutionEventType.GRAPH_EXEC_UPDATE: WSMethod.GRAPH_EXECUTION_EVENT, + ExecutionEventType.NODE_EXEC_UPDATE: WSMethod.NODE_EXECUTION_EVENT, +} class ConnectionManager: @@ -11,33 +20,96 @@ def __init__(self): self.active_connections: Set[WebSocket] = set() self.subscriptions: Dict[str, Set[WebSocket]] = {} - async def connect(self, websocket: WebSocket): + async def connect_socket(self, websocket: WebSocket): await websocket.accept() self.active_connections.add(websocket) - def disconnect(self, websocket: WebSocket): + def disconnect_socket(self, websocket: WebSocket): self.active_connections.remove(websocket) for subscribers in self.subscriptions.values(): subscribers.discard(websocket) - async def subscribe(self, graph_id: str, websocket: WebSocket): - if graph_id not in self.subscriptions: - self.subscriptions[graph_id] = set() - self.subscriptions[graph_id].add(websocket) - - async def unsubscribe(self, graph_id: str, websocket: WebSocket): - if graph_id in self.subscriptions: - self.subscriptions[graph_id].discard(websocket) - if not self.subscriptions[graph_id]: - del self.subscriptions[graph_id] - - async def send_execution_result(self, result: execution.ExecutionResult): - graph_id = result.graph_id - if graph_id in self.subscriptions: - message = WsMessage( - method=Methods.EXECUTION_EVENT, - channel=graph_id, - data=result.model_dump(), + async def subscribe_graph_exec( + self, *, user_id: str, graph_exec_id: str, websocket: WebSocket + ) -> str: + return await self._subscribe( + _graph_exec_channel_key(user_id, graph_exec_id=graph_exec_id), websocket + ) + + async def subscribe_graph_execs( + self, *, user_id: str, graph_id: str, websocket: WebSocket + ) -> str: + return await self._subscribe( + _graph_execs_channel_key(user_id, graph_id=graph_id), websocket + ) + + async def unsubscribe_graph_exec( + self, *, user_id: str, graph_exec_id: str, websocket: WebSocket + ) -> str | None: + return await self._unsubscribe( + _graph_exec_channel_key(user_id, graph_exec_id=graph_exec_id), websocket + ) + + async def unsubscribe_graph_execs( + self, *, user_id: str, graph_id: str, websocket: WebSocket + ) -> str | None: + return await self._unsubscribe( + _graph_execs_channel_key(user_id, graph_id=graph_id), websocket + ) + + async def send_execution_update( + self, exec_event: GraphExecutionEvent | NodeExecutionEvent + ) -> int: + graph_exec_id = ( + exec_event.id + if isinstance(exec_event, GraphExecutionEvent) + else exec_event.graph_exec_id + ) + + n_sent = 0 + + channels: set[str] = { + # Send update to listeners for this graph execution + _graph_exec_channel_key(exec_event.user_id, graph_exec_id=graph_exec_id) + } + if isinstance(exec_event, GraphExecutionEvent): + # Send update to listeners for all executions of this graph + channels.add( + _graph_execs_channel_key( + exec_event.user_id, graph_id=exec_event.graph_id + ) + ) + + for channel in channels.intersection(self.subscriptions.keys()): + message = WSMessage( + method=_EVENT_TYPE_TO_METHOD_MAP[exec_event.event_type], + channel=channel, + data=exec_event.model_dump(), ).model_dump_json() - for connection in self.subscriptions[graph_id]: + for connection in self.subscriptions[channel]: await connection.send_text(message) + n_sent += 1 + + return n_sent + + async def _subscribe(self, channel_key: str, websocket: WebSocket) -> str: + if channel_key not in self.subscriptions: + self.subscriptions[channel_key] = set() + self.subscriptions[channel_key].add(websocket) + return channel_key + + async def _unsubscribe(self, channel_key: str, websocket: WebSocket) -> str | None: + if channel_key in self.subscriptions: + self.subscriptions[channel_key].discard(websocket) + if not self.subscriptions[channel_key]: + del self.subscriptions[channel_key] + return channel_key + return None + + +def _graph_exec_channel_key(user_id: str, *, graph_exec_id: str) -> str: + return f"{user_id}|graph_exec#{graph_exec_id}" + + +def _graph_execs_channel_key(user_id: str, *, graph_id: str) -> str: + return f"{user_id}|graph#{graph_id}|executions" diff --git a/autogpt_platform/backend/backend/server/conn_manager_test.py b/autogpt_platform/backend/backend/server/conn_manager_test.py new file mode 100644 index 000000000000..69d984c7f25f --- /dev/null +++ b/autogpt_platform/backend/backend/server/conn_manager_test.py @@ -0,0 +1,206 @@ +from datetime import datetime, timezone +from unittest.mock import AsyncMock + +import pytest +from fastapi import WebSocket + +from backend.data.execution import ( + ExecutionStatus, + GraphExecutionEvent, + NodeExecutionEvent, +) +from backend.server.conn_manager import ConnectionManager +from backend.server.model import WSMessage, WSMethod + + +@pytest.fixture +def connection_manager() -> ConnectionManager: + return ConnectionManager() + + +@pytest.fixture +def mock_websocket() -> AsyncMock: + websocket: AsyncMock = AsyncMock(spec=WebSocket) + websocket.send_text = AsyncMock() + return websocket + + +@pytest.mark.asyncio +async def test_connect( + connection_manager: ConnectionManager, mock_websocket: AsyncMock +) -> None: + await connection_manager.connect_socket(mock_websocket) + assert mock_websocket in connection_manager.active_connections + mock_websocket.accept.assert_called_once() + + +def test_disconnect( + connection_manager: ConnectionManager, mock_websocket: AsyncMock +) -> None: + connection_manager.active_connections.add(mock_websocket) + connection_manager.subscriptions["test_channel_42"] = {mock_websocket} + + connection_manager.disconnect_socket(mock_websocket) + + assert mock_websocket not in connection_manager.active_connections + assert mock_websocket not in connection_manager.subscriptions["test_channel_42"] + + +@pytest.mark.asyncio +async def test_subscribe( + connection_manager: ConnectionManager, mock_websocket: AsyncMock +) -> None: + await connection_manager.subscribe_graph_exec( + user_id="user-1", + graph_exec_id="graph-exec-1", + websocket=mock_websocket, + ) + assert ( + mock_websocket + in connection_manager.subscriptions["user-1|graph_exec#graph-exec-1"] + ) + + +@pytest.mark.asyncio +async def test_unsubscribe( + connection_manager: ConnectionManager, mock_websocket: AsyncMock +) -> None: + channel_key = "user-1|graph_exec#graph-exec-1" + connection_manager.subscriptions[channel_key] = {mock_websocket} + + await connection_manager.unsubscribe_graph_exec( + user_id="user-1", + graph_exec_id="graph-exec-1", + websocket=mock_websocket, + ) + + assert "test_graph" not in connection_manager.subscriptions + + +@pytest.mark.asyncio +async def test_send_graph_execution_result( + connection_manager: ConnectionManager, mock_websocket: AsyncMock +) -> None: + channel_key = "user-1|graph_exec#graph-exec-1" + connection_manager.subscriptions[channel_key] = {mock_websocket} + result = GraphExecutionEvent( + id="graph-exec-1", + user_id="user-1", + graph_id="test_graph", + graph_version=1, + status=ExecutionStatus.COMPLETED, + started_at=datetime.now(tz=timezone.utc), + ended_at=datetime.now(tz=timezone.utc), + stats=GraphExecutionEvent.Stats( + cost=0, + duration=1.2, + node_exec_time=0.5, + node_exec_count=2, + ), + inputs={ + "input_1": "some input value :)", + "input_2": "some *other* input value", + }, + outputs={ + "the_output": ["some output value"], + "other_output": ["sike there was another output"], + }, + ) + + await connection_manager.send_execution_update(result) + + mock_websocket.send_text.assert_called_once_with( + WSMessage( + method=WSMethod.GRAPH_EXECUTION_EVENT, + channel="user-1|graph_exec#graph-exec-1", + data=result.model_dump(), + ).model_dump_json() + ) + + +@pytest.mark.asyncio +async def test_send_node_execution_result( + connection_manager: ConnectionManager, mock_websocket: AsyncMock +) -> None: + channel_key = "user-1|graph_exec#graph-exec-1" + connection_manager.subscriptions[channel_key] = {mock_websocket} + result = NodeExecutionEvent( + user_id="user-1", + graph_id="test_graph", + graph_version=1, + graph_exec_id="graph-exec-1", + node_exec_id="test_node_exec_id", + node_id="test_node_id", + block_id="test_block_id", + status=ExecutionStatus.COMPLETED, + input_data={"input1": "value1"}, + output_data={"output1": ["result1"]}, + add_time=datetime.now(tz=timezone.utc), + queue_time=None, + start_time=datetime.now(tz=timezone.utc), + end_time=datetime.now(tz=timezone.utc), + ) + + await connection_manager.send_execution_update(result) + + mock_websocket.send_text.assert_called_once_with( + WSMessage( + method=WSMethod.NODE_EXECUTION_EVENT, + channel="user-1|graph_exec#graph-exec-1", + data=result.model_dump(), + ).model_dump_json() + ) + + +@pytest.mark.asyncio +async def test_send_execution_result_user_mismatch( + connection_manager: ConnectionManager, mock_websocket: AsyncMock +) -> None: + channel_key = "user-1|graph_exec#graph-exec-1" + connection_manager.subscriptions[channel_key] = {mock_websocket} + result = NodeExecutionEvent( + user_id="user-2", + graph_id="test_graph", + graph_version=1, + graph_exec_id="graph-exec-1", + node_exec_id="test_node_exec_id", + node_id="test_node_id", + block_id="test_block_id", + status=ExecutionStatus.COMPLETED, + input_data={"input1": "value1"}, + output_data={"output1": ["result1"]}, + add_time=datetime.now(tz=timezone.utc), + queue_time=None, + start_time=datetime.now(tz=timezone.utc), + end_time=datetime.now(tz=timezone.utc), + ) + + await connection_manager.send_execution_update(result) + + mock_websocket.send_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_send_execution_result_no_subscribers( + connection_manager: ConnectionManager, mock_websocket: AsyncMock +) -> None: + result = NodeExecutionEvent( + user_id="user-1", + graph_id="test_graph", + graph_version=1, + graph_exec_id="test_exec_id", + node_exec_id="test_node_exec_id", + node_id="test_node_id", + block_id="test_block_id", + status=ExecutionStatus.COMPLETED, + input_data={"input1": "value1"}, + output_data={"output1": ["result1"]}, + add_time=datetime.now(), + queue_time=None, + start_time=datetime.now(), + end_time=datetime.now(), + ) + + await connection_manager.send_execution_update(result) + + mock_websocket.send_text.assert_not_called() diff --git a/autogpt_platform/backend/backend/server/external/api.py b/autogpt_platform/backend/backend/server/external/api.py new file mode 100644 index 000000000000..8ed15c138572 --- /dev/null +++ b/autogpt_platform/backend/backend/server/external/api.py @@ -0,0 +1,15 @@ +from fastapi import FastAPI + +from backend.server.middleware.security import SecurityHeadersMiddleware + +from .routes.v1 import v1_router + +external_app = FastAPI( + title="AutoGPT External API", + description="External API for AutoGPT integrations", + docs_url="/docs", + version="1.0", +) + +external_app.add_middleware(SecurityHeadersMiddleware) +external_app.include_router(v1_router, prefix="/v1") diff --git a/autogpt_platform/backend/backend/server/external/middleware.py b/autogpt_platform/backend/backend/server/external/middleware.py new file mode 100644 index 000000000000..2878e3d310d1 --- /dev/null +++ b/autogpt_platform/backend/backend/server/external/middleware.py @@ -0,0 +1,37 @@ +from fastapi import Depends, HTTPException, Request +from fastapi.security import APIKeyHeader +from prisma.enums import APIKeyPermission + +from backend.data.api_key import has_permission, validate_api_key + +api_key_header = APIKeyHeader(name="X-API-Key") + + +async def require_api_key(request: Request): + """Base middleware for API key authentication""" + api_key = await api_key_header(request) + + if api_key is None: + raise HTTPException(status_code=401, detail="Missing API key") + + api_key_obj = await validate_api_key(api_key) + + if not api_key_obj: + raise HTTPException(status_code=401, detail="Invalid API key") + + request.state.api_key = api_key_obj + return api_key_obj + + +def require_permission(permission: APIKeyPermission): + """Dependency function for checking specific permissions""" + + async def check_permission(api_key=Depends(require_api_key)): + if not has_permission(api_key, permission): + raise HTTPException( + status_code=403, + detail=f"API key missing required permission: {permission}", + ) + return api_key + + return check_permission diff --git a/autogpt_platform/backend/backend/server/external/routes/__init__.py b/autogpt_platform/backend/backend/server/external/routes/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/server/external/routes/v1.py b/autogpt_platform/backend/backend/server/external/routes/v1.py new file mode 100644 index 000000000000..5d8d366f775c --- /dev/null +++ b/autogpt_platform/backend/backend/server/external/routes/v1.py @@ -0,0 +1,143 @@ +import logging +from collections import defaultdict +from typing import Annotated, Any, Optional, Sequence + +from fastapi import APIRouter, Body, Depends, HTTPException +from prisma.enums import AgentExecutionStatus, APIKeyPermission +from typing_extensions import TypedDict + +import backend.data.block +from backend.data import execution as execution_db +from backend.data import graph as graph_db +from backend.data.api_key import APIKey +from backend.data.block import BlockInput, CompletedBlockOutput +from backend.executor.utils import add_graph_execution +from backend.server.external.middleware import require_permission +from backend.util.settings import Settings + +settings = Settings() +logger = logging.getLogger(__name__) + +v1_router = APIRouter() + + +class NodeOutput(TypedDict): + key: str + value: Any + + +class ExecutionNode(TypedDict): + node_id: str + input: Any + output: dict[str, Any] + + +class ExecutionNodeOutput(TypedDict): + node_id: str + outputs: list[NodeOutput] + + +class GraphExecutionResult(TypedDict): + execution_id: str + status: str + nodes: list[ExecutionNode] + output: Optional[list[dict[str, str]]] + + +@v1_router.get( + path="/blocks", + tags=["blocks"], + dependencies=[Depends(require_permission(APIKeyPermission.READ_BLOCK))], +) +def get_graph_blocks() -> Sequence[dict[Any, Any]]: + blocks = [block() for block in backend.data.block.get_blocks().values()] + return [b.to_dict() for b in blocks if not b.disabled] + + +@v1_router.post( + path="/blocks/{block_id}/execute", + tags=["blocks"], + dependencies=[Depends(require_permission(APIKeyPermission.EXECUTE_BLOCK))], +) +async def execute_graph_block( + block_id: str, + data: BlockInput, + api_key: APIKey = Depends(require_permission(APIKeyPermission.EXECUTE_BLOCK)), +) -> CompletedBlockOutput: + obj = backend.data.block.get_block(block_id) + if not obj: + raise HTTPException(status_code=404, detail=f"Block #{block_id} not found.") + + output = defaultdict(list) + async for name, data in obj.execute(data): + output[name].append(data) + return output + + +@v1_router.post( + path="/graphs/{graph_id}/execute/{graph_version}", + tags=["graphs"], +) +async def execute_graph( + graph_id: str, + graph_version: int, + node_input: Annotated[dict[str, Any], Body(..., embed=True, default_factory=dict)], + api_key: APIKey = Depends(require_permission(APIKeyPermission.EXECUTE_GRAPH)), +) -> dict[str, Any]: + try: + graph_exec = await add_graph_execution( + graph_id=graph_id, + user_id=api_key.user_id, + inputs=node_input, + graph_version=graph_version, + ) + return {"id": graph_exec.id} + except Exception as e: + msg = str(e).encode().decode("unicode_escape") + raise HTTPException(status_code=400, detail=msg) + + +@v1_router.get( + path="/graphs/{graph_id}/executions/{graph_exec_id}/results", + tags=["graphs"], +) +async def get_graph_execution_results( + graph_id: str, + graph_exec_id: str, + api_key: APIKey = Depends(require_permission(APIKeyPermission.READ_GRAPH)), +) -> GraphExecutionResult: + graph = await graph_db.get_graph(graph_id, user_id=api_key.user_id) + if not graph: + raise HTTPException(status_code=404, detail=f"Graph #{graph_id} not found.") + + graph_exec = await execution_db.get_graph_execution( + user_id=api_key.user_id, + execution_id=graph_exec_id, + include_node_executions=True, + ) + if not graph_exec: + raise HTTPException( + status_code=404, detail=f"Graph execution #{graph_exec_id} not found." + ) + + return GraphExecutionResult( + execution_id=graph_exec_id, + status=graph_exec.status.value, + nodes=[ + ExecutionNode( + node_id=node_exec.node_id, + input=node_exec.input_data.get("value", node_exec.input_data), + output={k: v for k, v in node_exec.output_data.items()}, + ) + for node_exec in graph_exec.node_executions + ], + output=( + [ + {name: value} + for name, values in graph_exec.outputs.items() + for value in values + ] + if graph_exec.status == AgentExecutionStatus.COMPLETED + else None + ), + ) diff --git a/autogpt_platform/backend/backend/server/integrations/models.py b/autogpt_platform/backend/backend/server/integrations/models.py new file mode 100644 index 000000000000..68e89ea7c262 --- /dev/null +++ b/autogpt_platform/backend/backend/server/integrations/models.py @@ -0,0 +1,74 @@ +""" +Models for integration-related data structures that need to be exposed in the OpenAPI schema. + +This module provides models that will be included in the OpenAPI schema generation, +allowing frontend code generators like Orval to create corresponding TypeScript types. +""" + +from pydantic import BaseModel, Field + +from backend.integrations.providers import ProviderName +from backend.sdk.registry import AutoRegistry + + +def get_all_provider_names() -> list[str]: + """ + Collect all provider names from both ProviderName enum and AutoRegistry. + + This function should be called at runtime to ensure we get all + dynamically registered providers. + + Returns: + A sorted list of unique provider names. + """ + # Get static providers from enum + static_providers = [member.value for member in ProviderName] + + # Get dynamic providers from registry + dynamic_providers = AutoRegistry.get_all_provider_names() + + # Combine and deduplicate + all_providers = list(set(static_providers + dynamic_providers)) + all_providers.sort() + + return all_providers + + +# Note: We don't create a static enum here because providers are registered dynamically. +# Instead, we expose provider names through API endpoints that can be fetched at runtime. + + +class ProviderNamesResponse(BaseModel): + """Response containing list of all provider names.""" + + providers: list[str] = Field( + description="List of all available provider names", + default_factory=get_all_provider_names, + ) + + +class ProviderConstants(BaseModel): + """ + Model that exposes all provider names as a constant in the OpenAPI schema. + This is designed to be converted by Orval into a TypeScript constant. + """ + + PROVIDER_NAMES: dict[str, str] = Field( + description="All available provider names as a constant mapping", + default_factory=lambda: { + name.upper().replace("-", "_"): name for name in get_all_provider_names() + }, + ) + + class Config: + schema_extra = { + "example": { + "PROVIDER_NAMES": { + "OPENAI": "openai", + "ANTHROPIC": "anthropic", + "EXA": "exa", + "GEM": "gem", + "EXAMPLE_SERVICE": "example-service", + } + } + } diff --git a/autogpt_platform/backend/backend/server/integrations/router.py b/autogpt_platform/backend/backend/server/integrations/router.py index b4964c790d69..f7ac03c4e630 100644 --- a/autogpt_platform/backend/backend/server/integrations/router.py +++ b/autogpt_platform/backend/backend/server/integrations/router.py @@ -1,10 +1,22 @@ +import asyncio import logging -from typing import TYPE_CHECKING, Annotated, Literal - -from fastapi import APIRouter, Body, Depends, HTTPException, Path, Query, Request +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Annotated, Awaitable, List, Literal + +from fastapi import ( + APIRouter, + Body, + Depends, + HTTPException, + Path, + Query, + Request, + status, +) from pydantic import BaseModel, Field, SecretStr +from starlette.status import HTTP_500_INTERNAL_SERVER_ERROR, HTTP_502_BAD_GATEWAY -from backend.data.graph import set_node_webhook +from backend.data.graph import get_graph, set_node_webhook from backend.data.integrations import ( WebhookEvent, get_all_webhooks_by_creds, @@ -13,18 +25,26 @@ wait_for_webhook_event, ) from backend.data.model import ( - APIKeyCredentials, Credentials, CredentialsType, + HostScopedCredentials, OAuth2Credentials, + UserIntegrations, ) -from backend.executor.manager import ExecutionManager +from backend.data.user import get_user_integrations +from backend.executor.utils import add_graph_execution +from backend.integrations.ayrshare import AyrshareClient, SocialPlatform from backend.integrations.creds_manager import IntegrationCredentialsManager -from backend.integrations.oauth import HANDLERS_BY_NAME +from backend.integrations.oauth import CREDENTIALS_BY_PROVIDER, HANDLERS_BY_NAME from backend.integrations.providers import ProviderName -from backend.integrations.webhooks import WEBHOOK_MANAGERS_BY_NAME -from backend.util.exceptions import NeedConfirmation -from backend.util.service import get_service_client +from backend.integrations.webhooks import get_webhook_manager +from backend.server.integrations.models import ( + ProviderConstants, + ProviderNamesResponse, + get_all_provider_names, +) +from backend.server.v2.library.db import set_preset_webhook, update_preset +from backend.util.exceptions import MissingConfigError, NeedConfirmation, NotFoundError from backend.util.settings import Settings if TYPE_CHECKING: @@ -45,7 +65,7 @@ class LoginResponse(BaseModel): @router.get("/{provider}/login") -def login( +async def login( provider: Annotated[ ProviderName, Path(title="The provider to initiate an OAuth flow for") ], @@ -60,11 +80,12 @@ def login( requested_scopes = scopes.split(",") if scopes else [] # Generate and store a secure random state token along with the scopes - state_token = creds_manager.store.store_state_token( + state_token, code_challenge = await creds_manager.store.store_state_token( user_id, provider, requested_scopes ) - - login_url = handler.get_login_url(requested_scopes, state_token) + login_url = handler.get_login_url( + requested_scopes, state_token, code_challenge=code_challenge + ) return LoginResponse(login_url=login_url, state_token=state_token) @@ -76,10 +97,13 @@ class CredentialsMetaResponse(BaseModel): title: str | None scopes: list[str] | None username: str | None + host: str | None = Field( + default=None, description="Host pattern for host-scoped credentials" + ) @router.post("/{provider}/callback") -def callback( +async def callback( provider: Annotated[ ProviderName, Path(title="The target provider for this OAuth exchange") ], @@ -92,21 +116,33 @@ def callback( handler = _get_provider_oauth_handler(request, provider) # Verify the state token - if not creds_manager.store.verify_state_token(user_id, state_token, provider): - logger.warning(f"Invalid or expired state token for user {user_id}") - raise HTTPException(status_code=400, detail="Invalid or expired state token") + valid_state = await creds_manager.store.verify_state_token( + user_id, state_token, provider + ) - try: - scopes = creds_manager.store.get_any_valid_scopes_from_state_token( - user_id, state_token, provider + if not valid_state: + logger.warning(f"Invalid or expired state token for user {user_id}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid or expired state token", ) + try: + scopes = valid_state.scopes logger.debug(f"Retrieved scopes from state token: {scopes}") scopes = handler.handle_default_scopes(scopes) - credentials = handler.exchange_code_for_tokens(code, scopes) + credentials = await handler.exchange_code_for_tokens( + code, scopes, valid_state.code_verifier + ) + logger.debug(f"Received credentials with final scopes: {credentials.scopes}") + # Linear returns scopes as a single string with spaces, so we need to split them + # TODO: make a bypass of this part of the OAuth handler + if len(credentials.scopes) == 1 and " " in credentials.scopes[0]: + credentials.scopes = credentials.scopes[0].split(" ") + # Check if the granted scopes are sufficient for the requested scopes if not set(scopes).issubset(set(credentials.scopes)): # For now, we'll just log the warning and continue @@ -116,13 +152,16 @@ def callback( ) except Exception as e: - logger.error(f"Code->Token exchange failed for provider {provider.value}: {e}") + logger.error( + f"OAuth2 Code->Token exchange failed for provider {provider.value}: {e}" + ) raise HTTPException( - status_code=400, detail=f"Failed to exchange code for tokens: {str(e)}" + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"OAuth2 callback failed to exchange code for tokens: {str(e)}", ) # TODO: Allow specifying `title` to set on `credentials` - creds_manager.create(user_id, credentials) + await creds_manager.create(user_id, credentials) logger.debug( f"Successfully processed OAuth callback for user {user_id} " @@ -135,14 +174,17 @@ def callback( title=credentials.title, scopes=credentials.scopes, username=credentials.username, + host=( + credentials.host if isinstance(credentials, HostScopedCredentials) else None + ), ) @router.get("/credentials") -def list_credentials( +async def list_credentials( user_id: Annotated[str, Depends(get_user_id)], ) -> list[CredentialsMetaResponse]: - credentials = creds_manager.store.get_all_creds(user_id) + credentials = await creds_manager.store.get_all_creds(user_id) return [ CredentialsMetaResponse( id=cred.id, @@ -151,19 +193,20 @@ def list_credentials( title=cred.title, scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None, username=cred.username if isinstance(cred, OAuth2Credentials) else None, + host=cred.host if isinstance(cred, HostScopedCredentials) else None, ) for cred in credentials ] @router.get("/{provider}/credentials") -def list_credentials_by_provider( +async def list_credentials_by_provider( provider: Annotated[ ProviderName, Path(title="The provider to list credentials for") ], user_id: Annotated[str, Depends(get_user_id)], ) -> list[CredentialsMetaResponse]: - credentials = creds_manager.store.get_creds_by_provider(user_id, provider) + credentials = await creds_manager.store.get_creds_by_provider(user_id, provider) return [ CredentialsMetaResponse( id=cred.id, @@ -172,55 +215,50 @@ def list_credentials_by_provider( title=cred.title, scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None, username=cred.username if isinstance(cred, OAuth2Credentials) else None, + host=cred.host if isinstance(cred, HostScopedCredentials) else None, ) for cred in credentials ] @router.get("/{provider}/credentials/{cred_id}") -def get_credential( +async def get_credential( provider: Annotated[ ProviderName, Path(title="The provider to retrieve credentials for") ], cred_id: Annotated[str, Path(title="The ID of the credentials to retrieve")], user_id: Annotated[str, Depends(get_user_id)], ) -> Credentials: - credential = creds_manager.get(user_id, cred_id) + credential = await creds_manager.get(user_id, cred_id) if not credential: - raise HTTPException(status_code=404, detail="Credentials not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Credentials not found" + ) if credential.provider != provider: raise HTTPException( - status_code=404, detail="Credentials do not match the specified provider" + status_code=status.HTTP_404_NOT_FOUND, + detail="Credentials do not match the specified provider", ) return credential @router.post("/{provider}/credentials", status_code=201) -def create_api_key_credentials( +async def create_credentials( user_id: Annotated[str, Depends(get_user_id)], provider: Annotated[ ProviderName, Path(title="The provider to create credentials for") ], - api_key: Annotated[str, Body(title="The API key to store")], - title: Annotated[str, Body(title="Optional title for the credentials")], - expires_at: Annotated[ - int | None, Body(title="Unix timestamp when the key expires") - ] = None, -) -> APIKeyCredentials: - new_credentials = APIKeyCredentials( - provider=provider, - api_key=SecretStr(api_key), - title=title, - expires_at=expires_at, - ) - + credentials: Credentials, +) -> Credentials: + credentials.provider = provider try: - creds_manager.create(user_id, new_credentials) + await creds_manager.create(user_id, credentials) except Exception as e: raise HTTPException( - status_code=500, detail=f"Failed to store credentials: {str(e)}" + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to store credentials: {str(e)}", ) - return new_credentials + return credentials class CredentialsDeletionResponse(BaseModel): @@ -238,6 +276,11 @@ class CredentialsDeletionNeedsConfirmationResponse(BaseModel): message: str +class AyrshareSSOResponse(BaseModel): + sso_url: str = Field(..., description="The SSO URL for Ayrshare integration") + expires_at: datetime = Field(..., description="ISO timestamp when the URL expires") + + @router.delete("/{provider}/credentials/{cred_id}") async def delete_credentials( request: Request, @@ -250,25 +293,28 @@ async def delete_credentials( bool, Query(title="Whether to proceed if any linked webhooks are still in use") ] = False, ) -> CredentialsDeletionResponse | CredentialsDeletionNeedsConfirmationResponse: - creds = creds_manager.store.get_creds_by_id(user_id, cred_id) + creds = await creds_manager.store.get_creds_by_id(user_id, cred_id) if not creds: - raise HTTPException(status_code=404, detail="Credentials not found") + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Credentials not found" + ) if creds.provider != provider: raise HTTPException( - status_code=404, detail="Credentials do not match the specified provider" + status_code=status.HTTP_404_NOT_FOUND, + detail="Credentials do not match the specified provider", ) try: - await remove_all_webhooks_for_credentials(creds, force) + await remove_all_webhooks_for_credentials(user_id, creds, force) except NeedConfirmation as e: return CredentialsDeletionNeedsConfirmationResponse(message=str(e)) - creds_manager.delete(user_id, cred_id) + await creds_manager.delete(user_id, cred_id) tokens_revoked = None if isinstance(creds, OAuth2Credentials): handler = _get_provider_oauth_handler(request, provider) - tokens_revoked = handler.revoke_tokens(creds) + tokens_revoked = await handler.revoke_tokens(creds) return CredentialsDeletionResponse(revoked=tokens_revoked) @@ -288,10 +334,22 @@ async def webhook_ingress_generic( webhook_id: Annotated[str, Path(title="Our ID for the webhook")], ): logger.debug(f"Received {provider.value} webhook ingress for ID {webhook_id}") - webhook_manager = WEBHOOK_MANAGERS_BY_NAME[provider]() - webhook = await get_webhook(webhook_id) + webhook_manager = get_webhook_manager(provider) + try: + webhook = await get_webhook(webhook_id, include_relations=True) + user_id = webhook.user_id + credentials = ( + await creds_manager.get(user_id, webhook.credentials_id) + if webhook.credentials_id + else None + ) + except NotFoundError as e: + logger.warning(f"Webhook payload received for unknown webhook #{webhook_id}") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) logger.debug(f"Webhook #{webhook_id}: {webhook}") - payload, event_type = await webhook_manager.validate_payload(webhook, request) + payload, event_type = await webhook_manager.validate_payload( + webhook, request, credentials + ) logger.debug( f"Validated {provider.value} {webhook.webhook_type} {event_type} event " f"with payload {payload}" @@ -306,21 +364,66 @@ async def webhook_ingress_generic( await publish_webhook_event(webhook_event) logger.debug(f"Webhook event published: {webhook_event}") - if not webhook.attached_nodes: + if not (webhook.triggered_nodes or webhook.triggered_presets): return - executor = get_service_client(ExecutionManager) - for node in webhook.attached_nodes: + executions: list[Awaitable] = [] + for node in webhook.triggered_nodes: logger.debug(f"Webhook-attached node: {node}") if not node.is_triggered_by_event_type(event_type): logger.debug(f"Node #{node.id} doesn't trigger on event {event_type}") continue logger.debug(f"Executing graph #{node.graph_id} node #{node.id}") - executor.add_execution( - node.graph_id, - data={f"webhook_{webhook_id}_payload": payload}, - user_id=webhook.user_id, + executions.append( + add_graph_execution( + user_id=webhook.user_id, + graph_id=node.graph_id, + graph_version=node.graph_version, + nodes_input_masks={node.id: {"payload": payload}}, + ) ) + for preset in webhook.triggered_presets: + logger.debug(f"Webhook-attached preset: {preset}") + if not preset.is_active: + logger.debug(f"Preset #{preset.id} is inactive") + continue + + graph = await get_graph(preset.graph_id, preset.graph_version, webhook.user_id) + if not graph: + logger.error( + f"User #{webhook.user_id} has preset #{preset.id} for graph " + f"#{preset.graph_id} v{preset.graph_version}, " + "but no access to the graph itself." + ) + logger.info(f"Automatically deactivating broken preset #{preset.id}") + await update_preset(preset.user_id, preset.id, is_active=False) + continue + if not (trigger_node := graph.webhook_input_node): + # NOTE: this should NEVER happen, but we log and handle it gracefully + logger.error( + f"Preset #{preset.id} is triggered by webhook #{webhook.id}, but graph " + f"#{preset.graph_id} v{preset.graph_version} has no webhook input node" + ) + await set_preset_webhook(preset.user_id, preset.id, None) + continue + if not trigger_node.block.is_triggered_by_event_type(preset.inputs, event_type): + logger.debug(f"Preset #{preset.id} doesn't trigger on event {event_type}") + continue + logger.debug(f"Executing preset #{preset.id} for webhook #{webhook.id}") + + executions.append( + add_graph_execution( + user_id=webhook.user_id, + graph_id=preset.graph_id, + preset_id=preset.id, + graph_version=preset.graph_version, + graph_credentials_inputs=preset.credentials, + nodes_input_masks={ + trigger_node.id: {**preset.inputs, "payload": payload} + }, + ) + ) + asyncio.gather(*executions) @router.post("/webhooks/{webhook_id}/ping") @@ -329,10 +432,10 @@ async def webhook_ping( user_id: Annotated[str, Depends(get_user_id)], # require auth ): webhook = await get_webhook(webhook_id) - webhook_manager = WEBHOOK_MANAGERS_BY_NAME[webhook.provider]() + webhook_manager = get_webhook_manager(webhook.provider) credentials = ( - creds_manager.get(user_id, webhook.credentials_id) + await creds_manager.get(user_id, webhook.credentials_id) if webhook.credentials_id else None ) @@ -342,7 +445,9 @@ async def webhook_ping( return False if not await wait_for_webhook_event(webhook_id, event_type="ping", timeout=10): - raise HTTPException(status_code=504, detail="Webhook ping timed out") + raise HTTPException( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, detail="Webhook ping timed out" + ) return True @@ -351,40 +456,37 @@ async def webhook_ping( async def remove_all_webhooks_for_credentials( - credentials: Credentials, force: bool = False + user_id: str, credentials: Credentials, force: bool = False ) -> None: """ Remove and deregister all webhooks that were registered using the given credentials. Params: + user_id: The ID of the user who owns the credentials and webhooks. credentials: The credentials for which to remove the associated webhooks. force: Whether to proceed if any of the webhooks are still in use. Raises: NeedConfirmation: If any of the webhooks are still in use and `force` is `False` """ - webhooks = await get_all_webhooks_by_creds(credentials.id) - if credentials.provider not in WEBHOOK_MANAGERS_BY_NAME: - if webhooks: - logger.error( - f"Credentials #{credentials.id} for provider {credentials.provider} " - f"are attached to {len(webhooks)} webhooks, " - f"but there is no available WebhooksHandler for {credentials.provider}" - ) - return - if any(w.attached_nodes for w in webhooks) and not force: + webhooks = await get_all_webhooks_by_creds( + user_id, credentials.id, include_relations=True + ) + if any(w.triggered_nodes or w.triggered_presets for w in webhooks) and not force: raise NeedConfirmation( "Some webhooks linked to these credentials are still in use by an agent" ) for webhook in webhooks: - # Unlink all nodes - for node in webhook.attached_nodes or []: + # Unlink all nodes & presets + for node in webhook.triggered_nodes: await set_node_webhook(node.id, None) + for preset in webhook.triggered_presets: + await set_preset_webhook(user_id, preset.id, None) # Prune the webhook - webhook_manager = WEBHOOK_MANAGERS_BY_NAME[credentials.provider]() + webhook_manager = get_webhook_manager(ProviderName(credentials.provider)) success = await webhook_manager.prune_webhook_if_dangling( - webhook.id, credentials + user_id, webhook.id, credentials ) if not success: logger.warning(f"Webhook #{webhook.id} failed to prune") @@ -393,30 +495,223 @@ async def remove_all_webhooks_for_credentials( def _get_provider_oauth_handler( req: Request, provider_name: ProviderName ) -> "BaseOAuthHandler": - if provider_name not in HANDLERS_BY_NAME: + # Ensure blocks are loaded so SDK providers are available + try: + from backend.blocks import load_all_blocks + + load_all_blocks() # This is cached, so it only runs once + except Exception as e: + logger.warning(f"Failed to load blocks: {e}") + + # Convert provider_name to string for lookup + provider_key = ( + provider_name.value if hasattr(provider_name, "value") else str(provider_name) + ) + + if provider_key not in HANDLERS_BY_NAME: raise HTTPException( - status_code=404, - detail=f"Provider '{provider_name.value}' does not support OAuth", + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Provider '{provider_key}' does not support OAuth", + ) + + # Check if this provider has custom OAuth credentials + oauth_credentials = CREDENTIALS_BY_PROVIDER.get(provider_key) + + if oauth_credentials and not oauth_credentials.use_secrets: + # SDK provider with custom env vars + import os + + client_id = ( + os.getenv(oauth_credentials.client_id_env_var) + if oauth_credentials.client_id_env_var + else None + ) + client_secret = ( + os.getenv(oauth_credentials.client_secret_env_var) + if oauth_credentials.client_secret_env_var + else None + ) + else: + # Original provider using settings.secrets + client_id = getattr(settings.secrets, f"{provider_name.value}_client_id", None) + client_secret = getattr( + settings.secrets, f"{provider_name.value}_client_secret", None ) - client_id = getattr(settings.secrets, f"{provider_name.value}_client_id") - client_secret = getattr(settings.secrets, f"{provider_name.value}_client_secret") if not (client_id and client_secret): + logger.error( + f"Attempt to use unconfigured {provider_name.value} OAuth integration" + ) raise HTTPException( - status_code=501, - detail=( - f"Integration with provider '{provider_name.value}' is not configured" - ), + status_code=status.HTTP_501_NOT_IMPLEMENTED, + detail={ + "message": f"Integration with provider '{provider_name.value}' is not configured.", + "hint": "Set client ID and secret in the application's deployment environment", + }, + ) + + handler_class = HANDLERS_BY_NAME[provider_key] + frontend_base_url = settings.config.frontend_base_url + + if not frontend_base_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Frontend base URL is not configured", ) - handler_class = HANDLERS_BY_NAME[provider_name] - frontend_base_url = ( - settings.config.frontend_base_url - or settings.config.platform_base_url - or str(req.base_url) - ) return handler_class( client_id=client_id, client_secret=client_secret, redirect_uri=f"{frontend_base_url}/auth/integrations/oauth_callback", ) + + +@router.get("/ayrshare/sso_url") +async def get_ayrshare_sso_url( + user_id: Annotated[str, Depends(get_user_id)], +) -> AyrshareSSOResponse: + """ + Generate an SSO URL for Ayrshare social media integration. + + Returns: + dict: Contains the SSO URL for Ayrshare integration + """ + try: + client = AyrshareClient() + except MissingConfigError: + raise HTTPException( + status_code=HTTP_500_INTERNAL_SERVER_ERROR, + detail="Ayrshare integration is not configured", + ) + + # Ayrshare profile key is stored in the credentials store + # It is generated when creating a new profile, if there is no profile key, + # we create a new profile and store the profile key in the credentials store + + user_integrations: UserIntegrations = await get_user_integrations(user_id) + profile_key = user_integrations.managed_credentials.ayrshare_profile_key + + if not profile_key: + logger.debug(f"Creating new Ayrshare profile for user {user_id}") + try: + profile = await client.create_profile( + title=f"User {user_id}", messaging_active=True + ) + profile_key = profile.profileKey + await creds_manager.store.set_ayrshare_profile_key(user_id, profile_key) + except Exception as e: + logger.error(f"Error creating Ayrshare profile for user {user_id}: {e}") + raise HTTPException( + status_code=HTTP_502_BAD_GATEWAY, + detail="Failed to create Ayrshare profile", + ) + else: + logger.debug(f"Using existing Ayrshare profile for user {user_id}") + + profile_key_str = ( + profile_key.get_secret_value() + if isinstance(profile_key, SecretStr) + else str(profile_key) + ) + + private_key = settings.secrets.ayrshare_jwt_key + # Ayrshare JWT expiry is 2880 minutes (48 hours) + max_expiry_minutes = 2880 + try: + logger.debug(f"Generating Ayrshare JWT for user {user_id}") + jwt_response = await client.generate_jwt( + private_key=private_key, + profile_key=profile_key_str, + allowed_social=[ + # NOTE: We are enabling platforms one at a time + # to speed up the development process + # SocialPlatform.FACEBOOK, + SocialPlatform.TWITTER, + SocialPlatform.LINKEDIN, + SocialPlatform.INSTAGRAM, + SocialPlatform.YOUTUBE, + # SocialPlatform.REDDIT, + # SocialPlatform.TELEGRAM, + # SocialPlatform.GOOGLE_MY_BUSINESS, + # SocialPlatform.PINTEREST, + SocialPlatform.TIKTOK, + # SocialPlatform.BLUESKY, + # SocialPlatform.SNAPCHAT, + # SocialPlatform.THREADS, + ], + expires_in=max_expiry_minutes, + verify=True, + ) + except Exception as e: + logger.error(f"Error generating Ayrshare JWT for user {user_id}: {e}") + raise HTTPException( + status_code=HTTP_502_BAD_GATEWAY, detail="Failed to generate JWT" + ) + + expires_at = datetime.now(timezone.utc) + timedelta(minutes=max_expiry_minutes) + return AyrshareSSOResponse(sso_url=jwt_response.url, expires_at=expires_at) + + +# === PROVIDER DISCOVERY ENDPOINTS === +@router.get("/providers", response_model=List[str]) +async def list_providers() -> List[str]: + """ + Get a list of all available provider names. + + Returns both statically defined providers (from ProviderName enum) + and dynamically registered providers (from SDK decorators). + + Note: The complete list of provider names is also available as a constant + in the generated TypeScript client via PROVIDER_NAMES. + """ + # Get all providers at runtime + all_providers = get_all_provider_names() + return all_providers + + +@router.get("/providers/names", response_model=ProviderNamesResponse) +async def get_provider_names() -> ProviderNamesResponse: + """ + Get all provider names in a structured format. + + This endpoint is specifically designed to expose the provider names + in the OpenAPI schema so that code generators like Orval can create + appropriate TypeScript constants. + """ + return ProviderNamesResponse() + + +@router.get("/providers/constants", response_model=ProviderConstants) +async def get_provider_constants() -> ProviderConstants: + """ + Get provider names as constants. + + This endpoint returns a model with provider names as constants, + specifically designed for OpenAPI code generation tools to create + TypeScript constants. + """ + return ProviderConstants() + + +class ProviderEnumResponse(BaseModel): + """Response containing a provider from the enum.""" + + provider: str = Field( + description="A provider name from the complete list of providers" + ) + + +@router.get("/providers/enum-example", response_model=ProviderEnumResponse) +async def get_provider_enum_example() -> ProviderEnumResponse: + """ + Example endpoint that uses the CompleteProviderNames enum. + + This endpoint exists to ensure that the CompleteProviderNames enum is included + in the OpenAPI schema, which will cause Orval to generate it as a + TypeScript enum/constant. + """ + # Return the first provider as an example + all_providers = get_all_provider_names() + return ProviderEnumResponse( + provider=all_providers[0] if all_providers else "openai" + ) diff --git a/autogpt_platform/backend/backend/server/integrations/utils.py b/autogpt_platform/backend/backend/server/integrations/utils.py deleted file mode 100644 index 0fa1052e5be0..000000000000 --- a/autogpt_platform/backend/backend/server/integrations/utils.py +++ /dev/null @@ -1,11 +0,0 @@ -from supabase import Client, create_client - -from backend.util.settings import Settings - -settings = Settings() - - -def get_supabase() -> Client: - return create_client( - settings.secrets.supabase_url, settings.secrets.supabase_service_role_key - ) diff --git a/autogpt_platform/backend/backend/server/middleware/security.py b/autogpt_platform/backend/backend/server/middleware/security.py new file mode 100644 index 000000000000..e6e23467ca48 --- /dev/null +++ b/autogpt_platform/backend/backend/server/middleware/security.py @@ -0,0 +1,93 @@ +import re +from typing import Set + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """ + Middleware to add security headers to responses, with cache control + disabled by default for all endpoints except those explicitly allowed. + """ + + CACHEABLE_PATHS: Set[str] = { + # Static assets + "/static", + "/_next/static", + "/assets", + "/images", + "/css", + "/js", + "/fonts", + # Public API endpoints + "/api/health", + "/api/v1/health", + "/api/status", + # Public store/marketplace pages (read-only) + "/api/store/agents", + "/api/v1/store/agents", + "/api/store/categories", + "/api/v1/store/categories", + "/api/store/featured", + "/api/v1/store/featured", + # Public graph templates (read-only, no user data) + "/api/graphs/templates", + "/api/v1/graphs/templates", + # Documentation endpoints + "/api/docs", + "/api/v1/docs", + "/docs", + "/swagger", + "/openapi.json", + # Favicon and manifest + "/favicon.ico", + "/manifest.json", + "/robots.txt", + "/sitemap.xml", + } + + def __init__(self, app: ASGIApp): + super().__init__(app) + # Compile regex patterns for wildcard matching + self.cacheable_patterns = [ + re.compile(pattern.replace("*", "[^/]+")) + for pattern in self.CACHEABLE_PATHS + if "*" in pattern + ] + self.exact_paths = {path for path in self.CACHEABLE_PATHS if "*" not in path} + + def is_cacheable_path(self, path: str) -> bool: + """Check if the given path is allowed to be cached.""" + # Check exact matches first + for cacheable_path in self.exact_paths: + if path.startswith(cacheable_path): + return True + + # Check pattern matches + for pattern in self.cacheable_patterns: + if pattern.match(path): + return True + + return False + + async def dispatch(self, request: Request, call_next): + response: Response = await call_next(request) + + # Add general security headers + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + + # Default: Disable caching for all endpoints + # Only allow caching for explicitly permitted paths + if not self.is_cacheable_path(request.url.path): + response.headers["Cache-Control"] = ( + "no-store, no-cache, must-revalidate, private" + ) + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" + + return response diff --git a/autogpt_platform/backend/backend/server/middleware/security_test.py b/autogpt_platform/backend/backend/server/middleware/security_test.py new file mode 100644 index 000000000000..462e5b27ed02 --- /dev/null +++ b/autogpt_platform/backend/backend/server/middleware/security_test.py @@ -0,0 +1,143 @@ +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from starlette.applications import Starlette + +from backend.server.middleware.security import SecurityHeadersMiddleware + + +@pytest.fixture +def app(): + """Create a test FastAPI app with security middleware.""" + app = FastAPI() + app.add_middleware(SecurityHeadersMiddleware) + + @app.get("/api/auth/user") + def get_user(): + return {"user": "test"} + + @app.get("/api/v1/integrations/oauth/google") + def oauth_endpoint(): + return {"oauth": "data"} + + @app.get("/api/graphs/123/execute") + def execute_graph(): + return {"execution": "data"} + + @app.get("/api/integrations/credentials") + def get_credentials(): + return {"credentials": "sensitive"} + + @app.get("/api/store/agents") + def store_agents(): + return {"agents": "public list"} + + @app.get("/api/health") + def health_check(): + return {"status": "ok"} + + @app.get("/static/logo.png") + def static_file(): + return {"static": "content"} + + return app + + +@pytest.fixture +def client(app): + """Create a test client.""" + return TestClient(app) + + +def test_non_cacheable_endpoints_have_cache_control_headers(client): + """Test that non-cacheable endpoints (most endpoints) have proper cache control headers.""" + non_cacheable_endpoints = [ + "/api/auth/user", + "/api/v1/integrations/oauth/google", + "/api/graphs/123/execute", + "/api/integrations/credentials", + ] + + for endpoint in non_cacheable_endpoints: + response = client.get(endpoint) + + # Check cache control headers are present (default behavior) + assert ( + response.headers["Cache-Control"] + == "no-store, no-cache, must-revalidate, private" + ) + assert response.headers["Pragma"] == "no-cache" + assert response.headers["Expires"] == "0" + + # Check general security headers + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["X-Frame-Options"] == "DENY" + assert response.headers["X-XSS-Protection"] == "1; mode=block" + assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin" + + +def test_cacheable_endpoints_dont_have_cache_control_headers(client): + """Test that explicitly cacheable endpoints don't have restrictive cache control headers.""" + cacheable_endpoints = [ + "/api/store/agents", + "/api/health", + "/static/logo.png", + ] + + for endpoint in cacheable_endpoints: + response = client.get(endpoint) + + # Should NOT have restrictive cache control headers + assert ( + "Cache-Control" not in response.headers + or "no-store" not in response.headers.get("Cache-Control", "") + ) + assert ( + "Pragma" not in response.headers + or response.headers.get("Pragma") != "no-cache" + ) + assert ( + "Expires" not in response.headers or response.headers.get("Expires") != "0" + ) + + # Should still have general security headers + assert response.headers["X-Content-Type-Options"] == "nosniff" + assert response.headers["X-Frame-Options"] == "DENY" + assert response.headers["X-XSS-Protection"] == "1; mode=block" + assert response.headers["Referrer-Policy"] == "strict-origin-when-cross-origin" + + +def test_is_cacheable_path_detection(): + """Test the path detection logic.""" + middleware = SecurityHeadersMiddleware(Starlette()) + + # Test cacheable paths (allow list) + assert middleware.is_cacheable_path("/api/health") + assert middleware.is_cacheable_path("/api/v1/health") + assert middleware.is_cacheable_path("/static/image.png") + assert middleware.is_cacheable_path("/api/store/agents") + assert middleware.is_cacheable_path("/docs") + assert middleware.is_cacheable_path("/favicon.ico") + + # Test non-cacheable paths (everything else) + assert not middleware.is_cacheable_path("/api/auth/user") + assert not middleware.is_cacheable_path("/api/v1/integrations/oauth/callback") + assert not middleware.is_cacheable_path("/api/integrations/credentials/123") + assert not middleware.is_cacheable_path("/api/graphs/abc123/execute") + assert not middleware.is_cacheable_path("/api/store/xyz/submissions") + + +def test_path_prefix_matching(): + """Test that path prefix matching works correctly.""" + middleware = SecurityHeadersMiddleware(Starlette()) + + # Test that paths starting with cacheable prefixes are cacheable + assert middleware.is_cacheable_path("/static/css/style.css") + assert middleware.is_cacheable_path("/static/js/app.js") + assert middleware.is_cacheable_path("/assets/images/logo.png") + assert middleware.is_cacheable_path("/_next/static/chunks/main.js") + + # Test that other API paths are not cacheable by default + assert not middleware.is_cacheable_path("/api/users/profile") + assert not middleware.is_cacheable_path("/api/v1/private/data") + assert not middleware.is_cacheable_path("/api/billing/subscription") diff --git a/autogpt_platform/backend/backend/server/model.py b/autogpt_platform/backend/backend/server/model.py index 7c554b445c53..8085cb3a81e5 100644 --- a/autogpt_platform/backend/backend/server/model.py +++ b/autogpt_platform/backend/backend/server/model.py @@ -1,47 +1,50 @@ import enum -from typing import Any, List, Optional, Union +from typing import Any, Optional import pydantic -import backend.data.graph from backend.data.api_key import APIKeyPermission, APIKeyWithoutHash +from backend.data.graph import Graph +from backend.util.timezone_name import TimeZoneName -class Methods(enum.Enum): - SUBSCRIBE = "subscribe" +class WSMethod(enum.Enum): + SUBSCRIBE_GRAPH_EXEC = "subscribe_graph_execution" + SUBSCRIBE_GRAPH_EXECS = "subscribe_graph_executions" UNSUBSCRIBE = "unsubscribe" - EXECUTION_EVENT = "execution_event" + GRAPH_EXECUTION_EVENT = "graph_execution_event" + NODE_EXECUTION_EVENT = "node_execution_event" ERROR = "error" HEARTBEAT = "heartbeat" -class WsMessage(pydantic.BaseModel): - method: Methods - data: Optional[Union[dict[str, Any], list[Any], str]] = None +class WSMessage(pydantic.BaseModel): + method: WSMethod + data: Optional[dict[str, Any] | list[Any] | str] = None success: bool | None = None channel: str | None = None error: str | None = None -class ExecutionSubscription(pydantic.BaseModel): - graph_id: str +class WSSubscribeGraphExecutionRequest(pydantic.BaseModel): + graph_exec_id: str -class SubscriptionDetails(pydantic.BaseModel): - event_type: str - channel: str +class WSSubscribeGraphExecutionsRequest(pydantic.BaseModel): graph_id: str +class ExecuteGraphResponse(pydantic.BaseModel): + graph_exec_id: str + + class CreateGraph(pydantic.BaseModel): - template_id: str | None = None - template_version: int | None = None - graph: backend.data.graph.Graph | None = None + graph: Graph class CreateAPIKeyRequest(pydantic.BaseModel): name: str - permissions: List[APIKeyPermission] + permissions: list[APIKeyPermission] description: Optional[str] = None @@ -55,4 +58,25 @@ class SetGraphActiveVersion(pydantic.BaseModel): class UpdatePermissionsRequest(pydantic.BaseModel): - permissions: List[APIKeyPermission] + permissions: list[APIKeyPermission] + + +class RequestTopUp(pydantic.BaseModel): + credit_amount: int + + +class UploadFileResponse(pydantic.BaseModel): + file_uri: str + file_name: str + size: int + content_type: str + expires_in_hours: int + + +class TimezoneResponse(pydantic.BaseModel): + # Allow "not-set" as a special value, or any valid IANA timezone + timezone: TimeZoneName | str + + +class UpdateTimezoneRequest(pydantic.BaseModel): + timezone: TimeZoneName diff --git a/autogpt_platform/backend/backend/server/rest_api.py b/autogpt_platform/backend/backend/server/rest_api.py index c5be1c179260..deeb85c39444 100644 --- a/autogpt_platform/backend/backend/server/rest_api.py +++ b/autogpt_platform/backend/backend/server/rest_api.py @@ -1,25 +1,45 @@ import contextlib import logging -import typing +from enum import Enum +from typing import Any, Optional +import autogpt_libs.auth.models import fastapi import fastapi.responses +import pydantic import starlette.middleware.cors import uvicorn -from autogpt_libs.feature_flag.client import ( - initialize_launchdarkly, - shutdown_launchdarkly, -) +from fastapi.exceptions import RequestValidationError +from fastapi.routing import APIRoute import backend.data.block import backend.data.db import backend.data.graph import backend.data.user +import backend.server.routers.postmark.postmark import backend.server.routers.v1 +import backend.server.v2.admin.credit_admin_routes +import backend.server.v2.admin.store_admin_routes +import backend.server.v2.builder +import backend.server.v2.builder.routes +import backend.server.v2.library.db +import backend.server.v2.library.model import backend.server.v2.library.routes +import backend.server.v2.otto.routes +import backend.server.v2.store.model import backend.server.v2.store.routes +import backend.server.v2.turnstile.routes import backend.util.service import backend.util.settings +from backend.blocks.llm import LlmModel +from backend.data.model import Credentials +from backend.integrations.providers import ProviderName +from backend.server.external.api import external_app +from backend.server.middleware.security import SecurityHeadersMiddleware +from backend.util import json +from backend.util.cloud_storage import shutdown_cloud_storage_handler +from backend.util.feature_flag import initialize_launchdarkly, shutdown_launchdarkly +from backend.util.service import UnhealthyServiceError settings = backend.util.settings.Settings() logger = logging.getLogger(__name__) @@ -42,14 +62,55 @@ def launch_darkly_context(): @contextlib.asynccontextmanager async def lifespan_context(app: fastapi.FastAPI): await backend.data.db.connect() + + # Ensure SDK auto-registration is patched before initializing blocks + from backend.sdk.registry import AutoRegistry + + AutoRegistry.patch_integrations() + await backend.data.block.initialize_blocks() + await backend.data.user.migrate_and_encrypt_user_integrations() await backend.data.graph.fix_llm_provider_credentials() + await backend.data.graph.migrate_llm_models(LlmModel.GPT4O) with launch_darkly_context(): yield + + try: + await shutdown_cloud_storage_handler() + except Exception as e: + logger.warning(f"Error shutting down cloud storage handler: {e}") + await backend.data.db.disconnect() +def custom_generate_unique_id(route: APIRoute): + """Generate clean operation IDs for OpenAPI spec following the format: + {method}{tag}{summary} + """ + if not route.tags or not route.methods: + return f"{route.name}" + + method = list(route.methods)[0].lower() + first_tag = route.tags[0] + if isinstance(first_tag, Enum): + tag_str = first_tag.name + else: + tag_str = str(first_tag) + + tag = "".join(word.capitalize() for word in tag_str.split("_")) # v1/v2 + + summary = ( + route.summary if route.summary else route.name + ) # need to be unique, a different version could have the same summary + summary = "".join(word.capitalize() for word in str(summary).split("_")) + + if tag: + return f"{method}{tag}{summary}" + else: + return f"{method}{summary}" + + docs_url = ( "/docs" if settings.config.app_env == backend.util.settings.AppEnvironment.LOCAL @@ -59,24 +120,38 @@ async def lifespan_context(app: fastapi.FastAPI): app = fastapi.FastAPI( title="AutoGPT Agent Server", description=( - "This server is used to execute agents that are created by the " - "AutoGPT system." + "This server is used to execute agents that are created by the AutoGPT system." ), summary="AutoGPT Agent Server", version="0.1", lifespan=lifespan_context, docs_url=docs_url, + generate_unique_id_function=custom_generate_unique_id, ) +app.add_middleware(SecurityHeadersMiddleware) + def handle_internal_http_error(status_code: int = 500, log_error: bool = True): def handler(request: fastapi.Request, exc: Exception): if log_error: - logger.exception(f"{request.method} {request.url.path} failed: {exc}") + logger.exception( + "%s %s failed. Investigate and resolve the underlying issue: %s", + request.method, + request.url.path, + exc, + ) + + hint = ( + "Adjust the request and retry." + if status_code < 500 + else "Check server logs and dependent services." + ) return fastapi.responses.JSONResponse( content={ - "message": f"{request.method} {request.url.path} failed", + "message": f"Failed to process {request.method} {request.url.path}", "detail": str(exc), + "hint": hint, }, status_code=status_code, ) @@ -84,19 +159,82 @@ def handler(request: fastapi.Request, exc: Exception): return handler +async def validation_error_handler( + request: fastapi.Request, exc: Exception +) -> fastapi.responses.Response: + logger.error( + "Validation failed for %s %s: %s. Fix the request payload and try again.", + request.method, + request.url.path, + exc, + ) + errors: list | str + if hasattr(exc, "errors"): + errors = exc.errors() # type: ignore[call-arg] + else: + errors = str(exc) + + response_content = { + "message": f"Invalid data for {request.method} {request.url.path}", + "detail": errors, + "hint": "Ensure the request matches the API schema.", + } + + content_json = json.dumps(response_content) + + return fastapi.responses.Response( + content=content_json, + status_code=422, + media_type="application/json", + ) + + +app.add_exception_handler(RequestValidationError, validation_error_handler) +app.add_exception_handler(pydantic.ValidationError, validation_error_handler) app.add_exception_handler(ValueError, handle_internal_http_error(400)) app.add_exception_handler(Exception, handle_internal_http_error(500)) app.include_router(backend.server.routers.v1.v1_router, tags=["v1"], prefix="/api") app.include_router( backend.server.v2.store.routes.router, tags=["v2"], prefix="/api/store" ) +app.include_router( + backend.server.v2.builder.routes.router, tags=["v2"], prefix="/api/builder" +) +app.include_router( + backend.server.v2.admin.store_admin_routes.router, + tags=["v2", "admin"], + prefix="/api/store", +) +app.include_router( + backend.server.v2.admin.credit_admin_routes.router, + tags=["v2", "admin"], + prefix="/api/credits", +) app.include_router( backend.server.v2.library.routes.router, tags=["v2"], prefix="/api/library" ) +app.include_router( + backend.server.v2.otto.routes.router, tags=["v2", "otto"], prefix="/api/otto" +) +app.include_router( + backend.server.v2.turnstile.routes.router, + tags=["v2", "turnstile"], + prefix="/api/turnstile", +) + +app.include_router( + backend.server.routers.postmark.postmark.router, + tags=["v1", "email"], + prefix="/api/email", +) + +app.mount("/external-api", external_app) @app.get(path="/health", tags=["health"], dependencies=[]) async def health(): + if not backend.data.db.is_connected(): + raise UnhealthyServiceError("Database is not connected") return {"status": "healthy"} @@ -113,13 +251,38 @@ def run(self): server_app, host=backend.util.settings.Config().agent_api_host, port=backend.util.settings.Config().agent_api_port, + log_config=None, ) + def cleanup(self): + super().cleanup() + logger.info(f"[{self.service_name}] ⏳ Shutting down Agent Server...") + @staticmethod async def test_execute_graph( - graph_id: str, node_input: dict[typing.Any, typing.Any], user_id: str + graph_id: str, + user_id: str, + graph_version: Optional[int] = None, + node_input: Optional[dict[str, Any]] = None, + ): + return await backend.server.routers.v1.execute_graph( + user_id=user_id, + graph_id=graph_id, + graph_version=graph_version, + inputs=node_input or {}, + credentials_inputs={}, + ) + + @staticmethod + async def test_get_graph( + graph_id: str, + graph_version: int, + user_id: str, + for_export: bool = False, ): - return backend.server.routers.v1.execute_graph(graph_id, node_input, user_id) + return await backend.server.routers.v1.get_graph( + graph_id, user_id, graph_version, for_export + ) @staticmethod async def test_create_graph( @@ -130,7 +293,9 @@ async def test_create_graph( @staticmethod async def test_get_graph_run_status(graph_exec_id: str, user_id: str): - execution = await backend.data.graph.get_execution( + from backend.data.execution import get_graph_execution_meta + + execution = await get_graph_execution_meta( user_id=user_id, execution_id=graph_exec_id ) if not execution: @@ -138,16 +303,101 @@ async def test_get_graph_run_status(graph_exec_id: str, user_id: str): return execution.status @staticmethod - async def test_get_graph_run_node_execution_results( - graph_id: str, graph_exec_id: str, user_id: str + async def test_delete_graph(graph_id: str, user_id: str): + """Used for clean-up after a test run""" + await backend.server.v2.library.db.delete_library_agent_by_graph_id( + graph_id=graph_id, user_id=user_id + ) + return await backend.server.routers.v1.delete_graph(graph_id, user_id) + + @staticmethod + async def test_get_presets(user_id: str, page: int = 1, page_size: int = 10): + return await backend.server.v2.library.routes.presets.list_presets( + user_id=user_id, page=page, page_size=page_size + ) + + @staticmethod + async def test_get_preset(preset_id: str, user_id: str): + return await backend.server.v2.library.routes.presets.get_preset( + preset_id=preset_id, user_id=user_id + ) + + @staticmethod + async def test_create_preset( + preset: backend.server.v2.library.model.LibraryAgentPresetCreatable, + user_id: str, ): - return await backend.server.routers.v1.get_graph_run_node_execution_results( - graph_id, graph_exec_id, user_id + return await backend.server.v2.library.routes.presets.create_preset( + preset=preset, user_id=user_id ) @staticmethod - async def test_delete_graph(graph_id: str, user_id: str): - return await backend.server.routers.v1.delete_graph(graph_id, user_id) + async def test_update_preset( + preset_id: str, + preset: backend.server.v2.library.model.LibraryAgentPresetUpdatable, + user_id: str, + ): + return await backend.server.v2.library.routes.presets.update_preset( + preset_id=preset_id, preset=preset, user_id=user_id + ) + + @staticmethod + async def test_delete_preset(preset_id: str, user_id: str): + return await backend.server.v2.library.routes.presets.delete_preset( + preset_id=preset_id, user_id=user_id + ) + + @staticmethod + async def test_execute_preset( + preset_id: str, + user_id: str, + inputs: Optional[dict[str, Any]] = None, + ): + return await backend.server.v2.library.routes.presets.execute_preset( + preset_id=preset_id, + user_id=user_id, + inputs=inputs or {}, + ) + + @staticmethod + async def test_create_store_listing( + request: backend.server.v2.store.model.StoreSubmissionRequest, user_id: str + ): + return await backend.server.v2.store.routes.create_submission(request, user_id) + + ### ADMIN ### + + @staticmethod + async def test_review_store_listing( + request: backend.server.v2.store.model.ReviewSubmissionRequest, + user: autogpt_libs.auth.models.User, + ): + return await backend.server.v2.admin.store_admin_routes.review_submission( + request.store_listing_version_id, request, user + ) + + @staticmethod + async def test_create_credentials( + user_id: str, + provider: ProviderName, + credentials: Credentials, + ) -> Credentials: + from backend.server.integrations.router import ( + create_credentials, + get_credential, + ) + + try: + return await create_credentials( + user_id=user_id, provider=provider, credentials=credentials + ) + except Exception as e: + logger.error(f"Error creating credentials: {e}") + return await get_credential( + provider=provider, + user_id=user_id, + cred_id=credentials.id, + ) def set_test_dependency_overrides(self, overrides: dict): app.dependency_overrides.update(overrides) diff --git a/autogpt_platform/backend/backend/server/routers/analytics.py b/autogpt_platform/backend/backend/server/routers/analytics.py index d7416c3e0e5a..cf0ae8572326 100644 --- a/autogpt_platform/backend/backend/server/routers/analytics.py +++ b/autogpt_platform/backend/backend/server/routers/analytics.py @@ -1,29 +1,48 @@ """Analytics API""" +import logging from typing import Annotated import fastapi +import pydantic import backend.data.analytics from backend.server.utils import get_user_id router = fastapi.APIRouter() +logger = logging.getLogger(__name__) + + +class LogRawMetricRequest(pydantic.BaseModel): + metric_name: str = pydantic.Field(..., min_length=1) + metric_value: float = pydantic.Field(..., allow_inf_nan=False) + data_string: str = pydantic.Field(..., min_length=1) @router.post(path="/log_raw_metric") async def log_raw_metric( user_id: Annotated[str, fastapi.Depends(get_user_id)], - metric_name: Annotated[str, fastapi.Body(..., embed=True)], - metric_value: Annotated[float, fastapi.Body(..., embed=True)], - data_string: Annotated[str, fastapi.Body(..., embed=True)], + request: LogRawMetricRequest, ): - result = await backend.data.analytics.log_raw_metric( - user_id=user_id, - metric_name=metric_name, - metric_value=metric_value, - data_string=data_string, - ) - return result.id + try: + result = await backend.data.analytics.log_raw_metric( + user_id=user_id, + metric_name=request.metric_name, + metric_value=request.metric_value, + data_string=request.data_string, + ) + return result.id + except Exception as e: + logger.exception( + "Failed to log metric %s for user %s: %s", request.metric_name, user_id, e + ) + raise fastapi.HTTPException( + status_code=500, + detail={ + "message": str(e), + "hint": "Check analytics service connection and retry.", + }, + ) @router.post("/log_raw_analytics") @@ -43,7 +62,14 @@ async def log_raw_analytics( ), ], ): - result = await backend.data.analytics.log_raw_analytics( - user_id, type, data, data_index - ) - return result.id + try: + result = await backend.data.analytics.log_raw_analytics( + user_id, type, data, data_index + ) + return result.id + except Exception as e: + logger.exception("Failed to log analytics for user %s: %s", user_id, e) + raise fastapi.HTTPException( + status_code=500, + detail={"message": str(e), "hint": "Ensure analytics DB is reachable."}, + ) diff --git a/autogpt_platform/backend/backend/server/routers/analytics_improved_test.py b/autogpt_platform/backend/backend/server/routers/analytics_improved_test.py new file mode 100644 index 000000000000..175057414212 --- /dev/null +++ b/autogpt_platform/backend/backend/server/routers/analytics_improved_test.py @@ -0,0 +1,148 @@ +"""Example of analytics tests with improved error handling and assertions.""" + +import json +from unittest.mock import AsyncMock, Mock + +import fastapi +import fastapi.testclient +import pytest_mock +from pytest_snapshot.plugin import Snapshot + +import backend.server.routers.analytics as analytics_routes +from backend.server.conftest import TEST_USER_ID +from backend.server.test_helpers import ( + assert_error_response_structure, + assert_mock_called_with_partial, + assert_response_status, + safe_parse_json, +) +from backend.server.utils import get_user_id + +app = fastapi.FastAPI() +app.include_router(analytics_routes.router) + +client = fastapi.testclient.TestClient(app) + + +def override_get_user_id() -> str: + """Override get_user_id for testing""" + return TEST_USER_ID + + +app.dependency_overrides[get_user_id] = override_get_user_id + + +def test_log_raw_metric_success_improved( + mocker: pytest_mock.MockFixture, + configured_snapshot: Snapshot, +) -> None: + """Test successful raw metric logging with improved assertions.""" + # Mock the analytics function + mock_result = Mock(id="metric-123-uuid") + + mock_log_metric = mocker.patch( + "backend.data.analytics.log_raw_metric", + new_callable=AsyncMock, + return_value=mock_result, + ) + + request_data = { + "metric_name": "page_load_time", + "metric_value": 2.5, + "data_string": "/dashboard", + } + + response = client.post("/log_raw_metric", json=request_data) + + # Improved assertions with better error messages + assert_response_status(response, 200, "Metric logging should succeed") + response_data = safe_parse_json(response, "Metric response parsing") + + assert response_data == "metric-123-uuid", f"Unexpected response: {response_data}" + + # Verify the function was called with correct parameters + assert_mock_called_with_partial( + mock_log_metric, + user_id=TEST_USER_ID, + metric_name="page_load_time", + metric_value=2.5, + data_string="/dashboard", + ) + + # Snapshot test the response + configured_snapshot.assert_match( + json.dumps({"metric_id": response_data}, indent=2, sort_keys=True), + "analytics_log_metric_success_improved", + ) + + +def test_log_raw_metric_invalid_request_improved() -> None: + """Test invalid metric request with improved error assertions.""" + # Test missing required fields + response = client.post("/log_raw_metric", json={}) + + error_data = assert_error_response_structure( + response, expected_status=422, expected_error_fields=["loc", "msg", "type"] + ) + + # Verify specific error details + detail = error_data["detail"] + assert isinstance(detail, list), "Error detail should be a list" + assert len(detail) > 0, "Should have at least one error" + + # Check that required fields are mentioned in errors + error_fields = [error["loc"][-1] for error in detail if "loc" in error] + assert "metric_name" in error_fields, "Should report missing metric_name" + assert "metric_value" in error_fields, "Should report missing metric_value" + assert "data_string" in error_fields, "Should report missing data_string" + + +def test_log_raw_metric_type_validation_improved( + mocker: pytest_mock.MockFixture, +) -> None: + """Test metric type validation with improved assertions.""" + # Mock the analytics function to avoid event loop issues + mocker.patch( + "backend.data.analytics.log_raw_metric", + new_callable=AsyncMock, + return_value=Mock(id="test-id"), + ) + + invalid_requests = [ + { + "data": { + "metric_name": "test", + "metric_value": "not_a_number", # Invalid type + "data_string": "test", + }, + "expected_error": "Input should be a valid number", + }, + { + "data": { + "metric_name": "", # Empty string + "metric_value": 1.0, + "data_string": "test", + }, + "expected_error": "String should have at least 1 character", + }, + { + "data": { + "metric_name": "test", + "metric_value": 123, # Valid number + "data_string": "", # Empty data_string + }, + "expected_error": "String should have at least 1 character", + }, + ] + + for test_case in invalid_requests: + response = client.post("/log_raw_metric", json=test_case["data"]) + + error_data = assert_error_response_structure(response, expected_status=422) + + # Check that expected error is in the response + error_text = json.dumps(error_data) + assert ( + test_case["expected_error"] in error_text + or test_case["expected_error"].lower() in error_text.lower() + ), f"Expected error '{test_case['expected_error']}' not found in: {error_text}" diff --git a/autogpt_platform/backend/backend/server/routers/analytics_parametrized_test.py b/autogpt_platform/backend/backend/server/routers/analytics_parametrized_test.py new file mode 100644 index 000000000000..a1250db942ea --- /dev/null +++ b/autogpt_platform/backend/backend/server/routers/analytics_parametrized_test.py @@ -0,0 +1,115 @@ +"""Example of parametrized tests for analytics endpoints.""" + +import json +from unittest.mock import AsyncMock, Mock + +import fastapi +import fastapi.testclient +import pytest +import pytest_mock +from pytest_snapshot.plugin import Snapshot + +import backend.server.routers.analytics as analytics_routes +from backend.server.conftest import TEST_USER_ID +from backend.server.utils import get_user_id + +app = fastapi.FastAPI() +app.include_router(analytics_routes.router) + +client = fastapi.testclient.TestClient(app) + + +def override_get_user_id() -> str: + """Override get_user_id for testing""" + return TEST_USER_ID + + +app.dependency_overrides[get_user_id] = override_get_user_id + + +@pytest.mark.parametrize( + "metric_value,metric_name,data_string,test_id", + [ + (100, "api_calls_count", "external_api", "integer_value"), + (0, "error_count", "no_errors", "zero_value"), + (-5.2, "temperature_delta", "cooling", "negative_value"), + (1.23456789, "precision_test", "float_precision", "float_precision"), + (999999999, "large_number", "max_value", "large_number"), + (0.0000001, "tiny_number", "min_value", "tiny_number"), + ], +) +def test_log_raw_metric_values_parametrized( + mocker: pytest_mock.MockFixture, + configured_snapshot: Snapshot, + metric_value: float, + metric_name: str, + data_string: str, + test_id: str, +) -> None: + """Test raw metric logging with various metric values using parametrize.""" + # Mock the analytics function + mock_result = Mock(id=f"metric-{test_id}-uuid") + + mocker.patch( + "backend.data.analytics.log_raw_metric", + new_callable=AsyncMock, + return_value=mock_result, + ) + + request_data = { + "metric_name": metric_name, + "metric_value": metric_value, + "data_string": data_string, + } + + response = client.post("/log_raw_metric", json=request_data) + + # Better error handling + assert response.status_code == 200, f"Failed for {test_id}: {response.text}" + response_data = response.json() + + # Snapshot test the response + configured_snapshot.assert_match( + json.dumps( + {"metric_id": response_data, "test_case": test_id}, indent=2, sort_keys=True + ), + f"analytics_metric_{test_id}", + ) + + +@pytest.mark.parametrize( + "invalid_data,expected_error", + [ + ({}, "Field required"), # Missing all fields + ({"metric_name": "test"}, "Field required"), # Missing metric_value + ( + {"metric_name": "test", "metric_value": "not_a_number"}, + "Input should be a valid number", + ), # Invalid type + ( + {"metric_name": "", "metric_value": 1.0, "data_string": "test"}, + "String should have at least 1 character", + ), # Empty name + ], +) +def test_log_raw_metric_invalid_requests_parametrized( + mocker: pytest_mock.MockFixture, + invalid_data: dict, + expected_error: str, +) -> None: + """Test invalid metric requests with parametrize.""" + # Mock the analytics function to avoid event loop issues + mocker.patch( + "backend.data.analytics.log_raw_metric", + new_callable=AsyncMock, + return_value=Mock(id="test-id"), + ) + + response = client.post("/log_raw_metric", json=invalid_data) + + assert response.status_code == 422 + error_detail = response.json() + assert "detail" in error_detail + # Verify error message contains expected error + error_text = json.dumps(error_detail) + assert expected_error in error_text or expected_error.lower() in error_text.lower() diff --git a/autogpt_platform/backend/backend/server/routers/analytics_test.py b/autogpt_platform/backend/backend/server/routers/analytics_test.py new file mode 100644 index 000000000000..11f97adfd2de --- /dev/null +++ b/autogpt_platform/backend/backend/server/routers/analytics_test.py @@ -0,0 +1,281 @@ +import json +from unittest.mock import AsyncMock, Mock + +import fastapi +import fastapi.testclient +import pytest_mock +from pytest_snapshot.plugin import Snapshot + +import backend.server.routers.analytics as analytics_routes +from backend.server.conftest import TEST_USER_ID +from backend.server.utils import get_user_id + +app = fastapi.FastAPI() +app.include_router(analytics_routes.router) + +client = fastapi.testclient.TestClient(app) + + +def override_get_user_id() -> str: + """Override get_user_id for testing""" + return TEST_USER_ID + + +app.dependency_overrides[get_user_id] = override_get_user_id + + +def test_log_raw_metric_success( + mocker: pytest_mock.MockFixture, + configured_snapshot: Snapshot, +) -> None: + """Test successful raw metric logging""" + + # Mock the analytics function + mock_result = Mock(id="metric-123-uuid") + + mock_log_metric = mocker.patch( + "backend.data.analytics.log_raw_metric", + new_callable=AsyncMock, + return_value=mock_result, + ) + + request_data = { + "metric_name": "page_load_time", + "metric_value": 2.5, + "data_string": "/dashboard", + } + + response = client.post("/log_raw_metric", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + assert response_data == "metric-123-uuid" + + # Verify the function was called with correct parameters + mock_log_metric.assert_called_once_with( + user_id=TEST_USER_ID, + metric_name="page_load_time", + metric_value=2.5, + data_string="/dashboard", + ) + + # Snapshot test the response + configured_snapshot.assert_match( + json.dumps({"metric_id": response.json()}, indent=2, sort_keys=True), + "analytics_log_metric_success", + ) + + +def test_log_raw_metric_various_values( + mocker: pytest_mock.MockFixture, + configured_snapshot: Snapshot, +) -> None: + """Test raw metric logging with various metric values""" + + # Mock the analytics function + mock_result = Mock(id="metric-456-uuid") + + mocker.patch( + "backend.data.analytics.log_raw_metric", + new_callable=AsyncMock, + return_value=mock_result, + ) + + # Test with integer value + request_data = { + "metric_name": "api_calls_count", + "metric_value": 100, + "data_string": "external_api", + } + + response = client.post("/log_raw_metric", json=request_data) + assert response.status_code == 200 + + # Test with zero value + request_data = { + "metric_name": "error_count", + "metric_value": 0, + "data_string": "no_errors", + } + + response = client.post("/log_raw_metric", json=request_data) + assert response.status_code == 200 + + # Test with negative value + request_data = { + "metric_name": "temperature_delta", + "metric_value": -5.2, + "data_string": "cooling", + } + + response = client.post("/log_raw_metric", json=request_data) + assert response.status_code == 200 + + # Snapshot the last response + configured_snapshot.assert_match( + json.dumps({"metric_id": response.json()}, indent=2, sort_keys=True), + "analytics_log_metric_various_values", + ) + + +def test_log_raw_analytics_success( + mocker: pytest_mock.MockFixture, + configured_snapshot: Snapshot, +) -> None: + """Test successful raw analytics logging""" + + # Mock the analytics function + mock_result = Mock(id="analytics-789-uuid") + + mock_log_analytics = mocker.patch( + "backend.data.analytics.log_raw_analytics", + new_callable=AsyncMock, + return_value=mock_result, + ) + + request_data = { + "type": "user_action", + "data": { + "action": "button_click", + "button_id": "submit_form", + "timestamp": "2023-01-01T00:00:00Z", + "metadata": { + "form_type": "registration", + "fields_filled": 5, + }, + }, + "data_index": "button_click_submit_form", + } + + response = client.post("/log_raw_analytics", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + assert response_data == "analytics-789-uuid" + + # Verify the function was called with correct parameters + mock_log_analytics.assert_called_once_with( + TEST_USER_ID, + "user_action", + request_data["data"], + "button_click_submit_form", + ) + + # Snapshot test the response + configured_snapshot.assert_match( + json.dumps({"analytics_id": response_data}, indent=2, sort_keys=True), + "analytics_log_analytics_success", + ) + + +def test_log_raw_analytics_complex_data( + mocker: pytest_mock.MockFixture, + configured_snapshot: Snapshot, +) -> None: + """Test raw analytics logging with complex nested data""" + + # Mock the analytics function + mock_result = Mock(id="analytics-complex-uuid") + + mocker.patch( + "backend.data.analytics.log_raw_analytics", + new_callable=AsyncMock, + return_value=mock_result, + ) + + request_data = { + "type": "agent_execution", + "data": { + "agent_id": "agent_123", + "execution_id": "exec_456", + "status": "completed", + "duration_ms": 3500, + "nodes_executed": 15, + "blocks_used": [ + {"block_id": "llm_block", "count": 3}, + {"block_id": "http_block", "count": 5}, + {"block_id": "code_block", "count": 2}, + ], + "errors": [], + "metadata": { + "trigger": "manual", + "user_tier": "premium", + "environment": "production", + }, + }, + "data_index": "agent_123_exec_456", + } + + response = client.post("/log_raw_analytics", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + + # Snapshot test the complex data structure + configured_snapshot.assert_match( + json.dumps( + { + "analytics_id": response_data, + "logged_data": request_data["data"], + }, + indent=2, + sort_keys=True, + ), + "analytics_log_analytics_complex_data", + ) + + +def test_log_raw_metric_invalid_request() -> None: + """Test raw metric logging with invalid request data""" + # Missing required fields + response = client.post("/log_raw_metric", json={}) + assert response.status_code == 422 + + # Invalid metric_value type + response = client.post( + "/log_raw_metric", + json={ + "metric_name": "test", + "metric_value": "not_a_number", + "data_string": "test", + }, + ) + assert response.status_code == 422 + + # Missing data_string + response = client.post( + "/log_raw_metric", + json={ + "metric_name": "test", + "metric_value": 1.0, + }, + ) + assert response.status_code == 422 + + +def test_log_raw_analytics_invalid_request() -> None: + """Test raw analytics logging with invalid request data""" + # Missing required fields + response = client.post("/log_raw_analytics", json={}) + assert response.status_code == 422 + + # Invalid data type (should be dict) + response = client.post( + "/log_raw_analytics", + json={ + "type": "test", + "data": "not_a_dict", + "data_index": "test", + }, + ) + assert response.status_code == 422 + + # Missing data_index + response = client.post( + "/log_raw_analytics", + json={ + "type": "test", + "data": {"key": "value"}, + }, + ) + assert response.status_code == 422 diff --git a/autogpt_platform/backend/backend/server/routers/postmark/__init__.py b/autogpt_platform/backend/backend/server/routers/postmark/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/server/routers/postmark/models.py b/autogpt_platform/backend/backend/server/routers/postmark/models.py new file mode 100644 index 000000000000..7d62c3f02622 --- /dev/null +++ b/autogpt_platform/backend/backend/server/routers/postmark/models.py @@ -0,0 +1,212 @@ +from enum import Enum +from typing import Literal + +from pydantic import BaseModel + + +# Models from https://account.postmarkapp.com/servers//streams/outbound/webhooks/new +class PostmarkDeliveryWebhook(BaseModel): + RecordType: Literal["Delivery"] = "Delivery" + ServerID: int + MessageStream: str + MessageID: str + Recipient: str + Tag: str + DeliveredAt: str + Details: str + Metadata: dict[str, str] + + +class PostmarkBounceEnum(Enum): + HardBounce = 1 + """ + The server was unable to deliver your message (ex: unknown user, mailbox not found). + """ + Transient = 2 + """ + The server could not temporarily deliver your message (ex: Message is delayed due to network troubles). + """ + Unsubscribe = 16 + """ + Unsubscribe or Remove request. + """ + Subscribe = 32 + """ + Subscribe request from someone wanting to get added to the mailing list. + """ + AutoResponder = 64 + """ + "Autoresponder" is an automatic email responder including nondescript NDRs and some "out of office" replies. + """ + AddressChange = 128 + """ + The recipient has requested an address change. + """ + DnsError = 256 + """ + A temporary DNS error. + """ + SpamNotification = 512 + """ + The message was delivered, but was either blocked by the user, or classified as spam, bulk mail, or had rejected content. + """ + OpenRelayTest = 1024 + """ + The NDR is actually a test email message to see if the mail server is an open relay. + """ + Unknown = 2048 + """ + Unable to classify the NDR. + """ + SoftBounce = 4096 + """ + Unable to temporarily deliver message (i.e. mailbox full, account disabled, exceeds quota, out of disk space). + """ + VirusNotification = 8192 + """ + The bounce is actually a virus notification warning about a virus/code infected message. + """ + ChallengeVerification = 16384 + """ + The bounce is a challenge asking for verification you actually sent the email. Typcial challenges are made by Spam Arrest, or MailFrontier Matador. + """ + BadEmailAddress = 100000 + """ + The address is not a valid email address. + """ + SpamComplaint = 100001 + """ + The subscriber explicitly marked this message as spam. + """ + ManuallyDeactivated = 100002 + """ + The email was manually deactivated. + """ + Unconfirmed = 100003 + """ + Registration not confirmed — The subscriber has not clicked on the confirmation link upon registration or import. + """ + Blocked = 100006 + """ + Blocked from this ISP due to content or blacklisting. + """ + SMTPApiError = 100007 + """ + An error occurred while accepting an email through the SMTP API. + """ + InboundError = 100008 + """ + Processing failed — Unable to deliver inbound message to destination inbound hook. + """ + DMARCPolicy = 100009 + """ + Email rejected due DMARC Policy. + """ + TemplateRenderingFailed = 100010 + """ + Template rendering failed — An error occurred while attempting to render your template. + """ + + +class PostmarkBounceWebhook(BaseModel): + RecordType: Literal["Bounce"] = "Bounce" + ID: int + Type: str + TypeCode: PostmarkBounceEnum + Tag: str + MessageID: str + Details: str + Email: str + From: str + BouncedAt: str + Inactive: bool + DumpAvailable: bool + CanActivate: bool + Subject: str + ServerID: int + MessageStream: str + Content: str + Name: str + Description: str + Metadata: dict[str, str] + + +class PostmarkSpamComplaintWebhook(BaseModel): + RecordType: Literal["SpamComplaint"] = "SpamComplaint" + ID: int + Type: str + TypeCode: int + Tag: str + MessageID: str + Details: str + Email: str + From: str + BouncedAt: str + Inactive: bool + DumpAvailable: bool + CanActivate: bool + Subject: str + ServerID: int + MessageStream: str + Content: str + Name: str + Description: str + Metadata: dict[str, str] + + +class PostmarkOpenWebhook(BaseModel): + RecordType: Literal["Open"] = "Open" + MessageStream: str + Metadata: dict[str, str] + FirstOpen: bool + Recipient: str + MessageID: str + ReceivedAt: str + Platform: str + ReadSeconds: int + Tag: str + UserAgent: str + OS: dict[str, str] + Client: dict[str, str] + Geo: dict[str, str] + + +class PostmarkClickWebhook(BaseModel): + RecordType: Literal["Click"] = "Click" + MessageStream: str + Metadata: dict[str, str] + Recipient: str + MessageID: str + ReceivedAt: str + Platform: str + ClickLocation: str + OriginalLink: str + Tag: str + UserAgent: str + OS: dict[str, str] + Client: dict[str, str] + Geo: dict[str, str] + + +class PostmarkSubscriptionChangeWebhook(BaseModel): + RecordType: Literal["SubscriptionChange"] = "SubscriptionChange" + MessageID: str + ServerID: int + MessageStream: str + ChangedAt: str + Recipient: str + Origin: str + SuppressSending: bool + SuppressionReason: str + Tag: str + Metadata: dict[str, str] + + +PostmarkWebhook = ( + PostmarkDeliveryWebhook + | PostmarkBounceWebhook + | PostmarkSpamComplaintWebhook + | PostmarkOpenWebhook + | PostmarkClickWebhook + | PostmarkSubscriptionChangeWebhook +) diff --git a/autogpt_platform/backend/backend/server/routers/postmark/postmark.py b/autogpt_platform/backend/backend/server/routers/postmark/postmark.py new file mode 100644 index 000000000000..b83b77dc1224 --- /dev/null +++ b/autogpt_platform/backend/backend/server/routers/postmark/postmark.py @@ -0,0 +1,129 @@ +import logging +from typing import Annotated + +from autogpt_libs.auth.middleware import APIKeyValidator +from fastapi import APIRouter, Body, Depends, HTTPException, Query +from fastapi.responses import JSONResponse + +from backend.data.user import ( + get_user_by_email, + set_user_email_verification, + unsubscribe_user_by_token, +) +from backend.server.routers.postmark.models import ( + PostmarkBounceEnum, + PostmarkBounceWebhook, + PostmarkClickWebhook, + PostmarkDeliveryWebhook, + PostmarkOpenWebhook, + PostmarkSpamComplaintWebhook, + PostmarkSubscriptionChangeWebhook, + PostmarkWebhook, +) +from backend.util.settings import Settings + +settings = Settings() +postmark_validator = APIKeyValidator( + "X-Postmark-Webhook-Token", + settings.secrets.postmark_webhook_token, +) + +router = APIRouter() + + +logger = logging.getLogger(__name__) + + +@router.post("/unsubscribe", summary="One Click Email Unsubscribe") +async def unsubscribe_via_one_click(token: Annotated[str, Query()]): + logger.info("Received unsubscribe request from One Click Unsubscribe") + try: + await unsubscribe_user_by_token(token) + except Exception as e: + logger.exception("Unsubscribe failed: %s", e) + raise HTTPException( + status_code=500, + detail={"message": str(e), "hint": "Verify Postmark token settings."}, + ) + return JSONResponse(status_code=200, content={"status": "ok"}) + + +@router.post( + "/", + dependencies=[Depends(postmark_validator.get_dependency())], + summary="Handle Postmark Email Webhooks", +) +async def postmark_webhook_handler( + webhook: Annotated[ + PostmarkWebhook, + Body(discriminator="RecordType"), + ] +): + logger.info(f"Received webhook from Postmark: {webhook}") + match webhook: + case PostmarkDeliveryWebhook(): + delivery_handler(webhook) + case PostmarkBounceWebhook(): + await bounce_handler(webhook) + case PostmarkSpamComplaintWebhook(): + spam_handler(webhook) + case PostmarkOpenWebhook(): + open_handler(webhook) + case PostmarkClickWebhook(): + click_handler(webhook) + case PostmarkSubscriptionChangeWebhook(): + subscription_handler(webhook) + case _: + logger.warning( + "Unhandled Postmark webhook type %s. Update handler mappings.", + type(webhook), + ) + return + + +async def bounce_handler(event: PostmarkBounceWebhook): + logger.info(f"Bounce handler {event=}") + if event.TypeCode in [ + PostmarkBounceEnum.Transient, + PostmarkBounceEnum.SoftBounce, + PostmarkBounceEnum.DnsError, + ]: + logger.info( + f"Softish bounce: {event.TypeCode} for {event.Email}, not setting email verification to false" + ) + return + logger.info(f"{event.Email=}") + user = await get_user_by_email(event.Email) + if not user: + logger.warning( + "Received bounce for unknown email %s. Ensure user records are current.", + event.Email, + ) + return + await set_user_email_verification(user.id, False) + logger.debug(f"Setting email verification to false for user: {user.id}") + + +def spam_handler(event: PostmarkSpamComplaintWebhook): + logger.info("Spam handler") + pass + + +def delivery_handler(event: PostmarkDeliveryWebhook): + logger.info("Delivery handler") + pass + + +def open_handler(event: PostmarkOpenWebhook): + logger.info("Open handler") + pass + + +def click_handler(event: PostmarkClickWebhook): + logger.info("Click handler") + pass + + +def subscription_handler(event: PostmarkSubscriptionChangeWebhook): + logger.info("Subscription handler") + pass diff --git a/autogpt_platform/backend/backend/server/routers/v1.py b/autogpt_platform/backend/backend/server/routers/v1.py index aca22e5c5d68..791b6e526ed2 100644 --- a/autogpt_platform/backend/backend/server/routers/v1.py +++ b/autogpt_platform/backend/backend/server/routers/v1.py @@ -1,18 +1,31 @@ import asyncio +import base64 import logging from collections import defaultdict -from typing import TYPE_CHECKING, Annotated, Any, Sequence +from datetime import datetime +from typing import Annotated, Any, Sequence import pydantic +import stripe from autogpt_libs.auth.middleware import auth_middleware -from autogpt_libs.feature_flag.client import feature_flag -from autogpt_libs.utils.cache import thread_cached -from fastapi import APIRouter, Depends, HTTPException +from fastapi import ( + APIRouter, + Body, + Depends, + File, + HTTPException, + Path, + Query, + Request, + Response, + UploadFile, +) +from starlette.status import HTTP_204_NO_CONTENT, HTTP_404_NOT_FOUND from typing_extensions import Optional, TypedDict -import backend.data.block import backend.server.integrations.router import backend.server.routers.analytics +import backend.server.v2.library.db as library_db from backend.data import execution as execution_db from backend.data import graph as graph_db from backend.data.api_key import ( @@ -27,11 +40,35 @@ suspend_api_key, update_api_key_permissions, ) -from backend.data.block import BlockInput, CompletedBlockOutput -from backend.data.credit import get_block_costs, get_user_credit_model -from backend.data.user import get_or_create_user -from backend.executor import ExecutionManager, ExecutionScheduler, scheduler -from backend.integrations.creds_manager import IntegrationCredentialsManager +from backend.data.block import BlockInput, CompletedBlockOutput, get_block, get_blocks +from backend.data.credit import ( + AutoTopUpConfig, + RefundRequest, + TransactionHistory, + get_auto_top_up, + get_block_costs, + get_user_credit_model, + set_auto_top_up, +) +from backend.data.model import CredentialsMetaInput +from backend.data.notifications import NotificationPreference, NotificationPreferenceDTO +from backend.data.onboarding import ( + UserOnboardingUpdate, + get_recommended_agents, + get_user_onboarding, + onboarding_enabled, + update_user_onboarding, +) +from backend.data.user import ( + get_or_create_user, + get_user_by_id, + get_user_notification_preference, + update_user_email, + update_user_notification_preference, + update_user_timezone, +) +from backend.executor import scheduler +from backend.executor import utils as execution_utils from backend.integrations.webhooks.graph_lifecycle_hooks import ( on_graph_activate, on_graph_deactivate, @@ -40,31 +77,37 @@ CreateAPIKeyRequest, CreateAPIKeyResponse, CreateGraph, + ExecuteGraphResponse, + RequestTopUp, SetGraphActiveVersion, + TimezoneResponse, UpdatePermissionsRequest, + UpdateTimezoneRequest, + UploadFileResponse, ) from backend.server.utils import get_user_id -from backend.util.service import get_service_client +from backend.util.clients import get_scheduler_client +from backend.util.cloud_storage import get_cloud_storage_handler +from backend.util.exceptions import GraphValidationError, NotFoundError from backend.util.settings import Settings - -if TYPE_CHECKING: - from backend.data.model import Credentials - - -@thread_cached -def execution_manager_client() -> ExecutionManager: - return get_service_client(ExecutionManager) +from backend.util.timezone_utils import ( + convert_cron_to_utc, + convert_utc_time_to_user_timezone, + get_user_timezone_or_utc, +) +from backend.util.virus_scanner import scan_content_safe -@thread_cached -def execution_scheduler_client() -> ExecutionScheduler: - return get_service_client(ExecutionScheduler) +def _create_file_size_error(size_bytes: int, max_size_mb: int) -> HTTPException: + """Create standardized file size error response.""" + return HTTPException( + status_code=400, + detail=f"File size ({size_bytes} bytes) exceeds the maximum allowed size of {max_size_mb}MB", + ) settings = Settings() logger = logging.getLogger(__name__) -integration_creds_manager = IntegrationCredentialsManager() - _user_credit_model = get_user_credit_model() @@ -90,51 +133,442 @@ def execution_scheduler_client() -> ExecutionScheduler: ######################################################## -@v1_router.post("/auth/user", tags=["auth"], dependencies=[Depends(auth_middleware)]) +@v1_router.post( + "/auth/user", + summary="Get or create user", + tags=["auth"], + dependencies=[Depends(auth_middleware)], +) async def get_or_create_user_route(user_data: dict = Depends(auth_middleware)): user = await get_or_create_user(user_data) return user.model_dump() +@v1_router.post( + "/auth/user/email", + summary="Update user email", + tags=["auth"], + dependencies=[Depends(auth_middleware)], +) +async def update_user_email_route( + user_id: Annotated[str, Depends(get_user_id)], email: str = Body(...) +) -> dict[str, str]: + await update_user_email(user_id, email) + + return {"email": email} + + +@v1_router.get( + "/auth/user/timezone", + summary="Get user timezone", + tags=["auth"], + dependencies=[Depends(auth_middleware)], +) +async def get_user_timezone_route( + user_data: dict = Depends(auth_middleware), +) -> TimezoneResponse: + """Get user timezone setting.""" + user = await get_or_create_user(user_data) + return TimezoneResponse(timezone=user.timezone) + + +@v1_router.post( + "/auth/user/timezone", + summary="Update user timezone", + tags=["auth"], + dependencies=[Depends(auth_middleware)], + response_model=TimezoneResponse, +) +async def update_user_timezone_route( + user_id: Annotated[str, Depends(get_user_id)], request: UpdateTimezoneRequest +) -> TimezoneResponse: + """Update user timezone. The timezone should be a valid IANA timezone identifier.""" + user = await update_user_timezone(user_id, str(request.timezone)) + return TimezoneResponse(timezone=user.timezone) + + +@v1_router.get( + "/auth/user/preferences", + summary="Get notification preferences", + tags=["auth"], + dependencies=[Depends(auth_middleware)], +) +async def get_preferences( + user_id: Annotated[str, Depends(get_user_id)], +) -> NotificationPreference: + preferences = await get_user_notification_preference(user_id) + return preferences + + +@v1_router.post( + "/auth/user/preferences", + summary="Update notification preferences", + tags=["auth"], + dependencies=[Depends(auth_middleware)], +) +async def update_preferences( + user_id: Annotated[str, Depends(get_user_id)], + preferences: NotificationPreferenceDTO = Body(...), +) -> NotificationPreference: + output = await update_user_notification_preference(user_id, preferences) + return output + + +######################################################## +##################### Onboarding ####################### +######################################################## + + +@v1_router.get( + "/onboarding", + summary="Get onboarding status", + tags=["onboarding"], + dependencies=[Depends(auth_middleware)], +) +async def get_onboarding(user_id: Annotated[str, Depends(get_user_id)]): + return await get_user_onboarding(user_id) + + +@v1_router.patch( + "/onboarding", + summary="Update onboarding progress", + tags=["onboarding"], + dependencies=[Depends(auth_middleware)], +) +async def update_onboarding( + user_id: Annotated[str, Depends(get_user_id)], data: UserOnboardingUpdate +): + return await update_user_onboarding(user_id, data) + + +@v1_router.get( + "/onboarding/agents", + summary="Get recommended agents", + tags=["onboarding"], + dependencies=[Depends(auth_middleware)], +) +async def get_onboarding_agents( + user_id: Annotated[str, Depends(get_user_id)], +): + return await get_recommended_agents(user_id) + + +@v1_router.get( + "/onboarding/enabled", + summary="Check onboarding enabled", + tags=["onboarding", "public"], + dependencies=[Depends(auth_middleware)], +) +async def is_onboarding_enabled(): + return await onboarding_enabled() + + ######################################################## ##################### Blocks ########################### ######################################################## -@v1_router.get(path="/blocks", tags=["blocks"], dependencies=[Depends(auth_middleware)]) +@v1_router.get( + path="/blocks", + summary="List available blocks", + tags=["blocks"], + dependencies=[Depends(auth_middleware)], +) def get_graph_blocks() -> Sequence[dict[Any, Any]]: - blocks = [block() for block in backend.data.block.get_blocks().values()] + blocks = [block() for block in get_blocks().values()] costs = get_block_costs() - return [{**b.to_dict(), "costs": costs.get(b.id, [])} for b in blocks] + return [ + {**b.to_dict(), "costs": costs.get(b.id, [])} for b in blocks if not b.disabled + ] @v1_router.post( path="/blocks/{block_id}/execute", + summary="Execute graph block", tags=["blocks"], dependencies=[Depends(auth_middleware)], ) -def execute_graph_block(block_id: str, data: BlockInput) -> CompletedBlockOutput: - obj = backend.data.block.get_block(block_id) +async def execute_graph_block(block_id: str, data: BlockInput) -> CompletedBlockOutput: + obj = get_block(block_id) if not obj: raise HTTPException(status_code=404, detail=f"Block #{block_id} not found.") output = defaultdict(list) - for name, data in obj.execute(data): + async for name, data in obj.execute(data): output[name].append(data) return output +@v1_router.post( + path="/files/upload", + summary="Upload file to cloud storage", + tags=["files"], + dependencies=[Depends(auth_middleware)], +) +async def upload_file( + user_id: Annotated[str, Depends(get_user_id)], + file: UploadFile = File(...), + provider: str = "gcs", + expiration_hours: int = 24, +) -> UploadFileResponse: + """ + Upload a file to cloud storage and return a storage key that can be used + with FileStoreBlock and AgentFileInputBlock. + + Args: + file: The file to upload + user_id: The user ID + provider: Cloud storage provider ("gcs", "s3", "azure") + expiration_hours: Hours until file expires (1-48) + + Returns: + Dict containing the cloud storage path and signed URL + """ + if expiration_hours < 1 or expiration_hours > 48: + raise HTTPException( + status_code=400, detail="Expiration hours must be between 1 and 48" + ) + + # Check file size limit before reading content to avoid memory issues + max_size_mb = settings.config.upload_file_size_limit_mb + max_size_bytes = max_size_mb * 1024 * 1024 + + # Try to get file size from headers first + if hasattr(file, "size") and file.size is not None and file.size > max_size_bytes: + raise _create_file_size_error(file.size, max_size_mb) + + # Read file content + content = await file.read() + content_size = len(content) + + # Double-check file size after reading (in case header was missing/incorrect) + if content_size > max_size_bytes: + raise _create_file_size_error(content_size, max_size_mb) + + # Extract common variables + file_name = file.filename or "uploaded_file" + content_type = file.content_type or "application/octet-stream" + + # Virus scan the content + await scan_content_safe(content, filename=file_name) + + # Check if cloud storage is configured + cloud_storage = await get_cloud_storage_handler() + if not cloud_storage.config.gcs_bucket_name: + # Fallback to base64 data URI when GCS is not configured + base64_content = base64.b64encode(content).decode("utf-8") + data_uri = f"data:{content_type};base64,{base64_content}" + + return UploadFileResponse( + file_uri=data_uri, + file_name=file_name, + size=content_size, + content_type=content_type, + expires_in_hours=expiration_hours, + ) + + # Store in cloud storage + storage_path = await cloud_storage.store_file( + content=content, + filename=file_name, + provider=provider, + expiration_hours=expiration_hours, + user_id=user_id, + ) + + return UploadFileResponse( + file_uri=storage_path, + file_name=file_name, + size=content_size, + content_type=content_type, + expires_in_hours=expiration_hours, + ) + + ######################################################## ##################### Credits ########################## ######################################################## -@v1_router.get(path="/credits", dependencies=[Depends(auth_middleware)]) +@v1_router.get( + path="/credits", + tags=["credits"], + summary="Get user credits", + dependencies=[Depends(auth_middleware)], +) async def get_user_credits( user_id: Annotated[str, Depends(get_user_id)], ) -> dict[str, int]: - # Credits can go negative, so ensure it's at least 0 for user to see. - return {"credits": max(await _user_credit_model.get_or_refill_credit(user_id), 0)} + return {"credits": await _user_credit_model.get_credits(user_id)} + + +@v1_router.post( + path="/credits", + summary="Request credit top up", + tags=["credits"], + dependencies=[Depends(auth_middleware)], +) +async def request_top_up( + request: RequestTopUp, user_id: Annotated[str, Depends(get_user_id)] +): + checkout_url = await _user_credit_model.top_up_intent( + user_id, request.credit_amount + ) + return {"checkout_url": checkout_url} + + +@v1_router.post( + path="/credits/{transaction_key}/refund", + summary="Refund credit transaction", + tags=["credits"], + dependencies=[Depends(auth_middleware)], +) +async def refund_top_up( + user_id: Annotated[str, Depends(get_user_id)], + transaction_key: str, + metadata: dict[str, str], +) -> int: + return await _user_credit_model.top_up_refund(user_id, transaction_key, metadata) + + +@v1_router.patch( + path="/credits", + summary="Fulfill checkout session", + tags=["credits"], + dependencies=[Depends(auth_middleware)], +) +async def fulfill_checkout(user_id: Annotated[str, Depends(get_user_id)]): + await _user_credit_model.fulfill_checkout(user_id=user_id) + return Response(status_code=200) + + +@v1_router.post( + path="/credits/auto-top-up", + summary="Configure auto top up", + tags=["credits"], + dependencies=[Depends(auth_middleware)], +) +async def configure_user_auto_top_up( + request: AutoTopUpConfig, user_id: Annotated[str, Depends(get_user_id)] +) -> str: + if request.threshold < 0: + raise ValueError("Threshold must be greater than 0") + if request.amount < 500 and request.amount != 0: + raise ValueError("Amount must be greater than or equal to 500") + if request.amount < request.threshold: + raise ValueError("Amount must be greater than or equal to threshold") + + current_balance = await _user_credit_model.get_credits(user_id) + + if current_balance < request.threshold: + await _user_credit_model.top_up_credits(user_id, request.amount) + else: + await _user_credit_model.top_up_credits(user_id, 0) + + await set_auto_top_up( + user_id, AutoTopUpConfig(threshold=request.threshold, amount=request.amount) + ) + return "Auto top-up settings updated" + + +@v1_router.get( + path="/credits/auto-top-up", + summary="Get auto top up", + tags=["credits"], + dependencies=[Depends(auth_middleware)], +) +async def get_user_auto_top_up( + user_id: Annotated[str, Depends(get_user_id)], +) -> AutoTopUpConfig: + return await get_auto_top_up(user_id) + + +@v1_router.post( + path="/credits/stripe_webhook", summary="Handle Stripe webhooks", tags=["credits"] +) +async def stripe_webhook(request: Request): + # Get the raw request body + payload = await request.body() + # Get the signature header + sig_header = request.headers.get("stripe-signature") + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, settings.secrets.stripe_webhook_secret + ) + except ValueError as e: + # Invalid payload + raise HTTPException( + status_code=400, detail=f"Invalid payload: {str(e) or type(e).__name__}" + ) + except stripe.SignatureVerificationError as e: + # Invalid signature + raise HTTPException( + status_code=400, detail=f"Invalid signature: {str(e) or type(e).__name__}" + ) + + if ( + event["type"] == "checkout.session.completed" + or event["type"] == "checkout.session.async_payment_succeeded" + ): + await _user_credit_model.fulfill_checkout( + session_id=event["data"]["object"]["id"] + ) + + if event["type"] == "charge.dispute.created": + await _user_credit_model.handle_dispute(event["data"]["object"]) + + if event["type"] == "refund.created" or event["type"] == "charge.dispute.closed": + await _user_credit_model.deduct_credits(event["data"]["object"]) + + return Response(status_code=200) + + +@v1_router.get( + path="/credits/manage", + tags=["credits"], + summary="Manage payment methods", + dependencies=[Depends(auth_middleware)], +) +async def manage_payment_method( + user_id: Annotated[str, Depends(get_user_id)], +) -> dict[str, str]: + return {"url": await _user_credit_model.create_billing_portal_session(user_id)} + + +@v1_router.get( + path="/credits/transactions", + tags=["credits"], + summary="Get credit history", + dependencies=[Depends(auth_middleware)], +) +async def get_credit_history( + user_id: Annotated[str, Depends(get_user_id)], + transaction_time: datetime | None = None, + transaction_type: str | None = None, + transaction_count_limit: int = 100, +) -> TransactionHistory: + if transaction_count_limit < 1 or transaction_count_limit > 1000: + raise ValueError("Transaction count limit must be between 1 and 1000") + + return await _user_credit_model.get_transaction_history( + user_id=user_id, + transaction_time_ceiling=transaction_time, + transaction_count_limit=transaction_count_limit, + transaction_type=transaction_type, + ) + + +@v1_router.get( + path="/credits/refunds", + tags=["credits"], + summary="Get refund requests", + dependencies=[Depends(auth_middleware)], +) +async def get_refund_requests( + user_id: Annotated[str, Depends(get_user_id)], +) -> list[RefundRequest]: + return await _user_credit_model.get_refund_requests(user_id) ######################################################## @@ -146,18 +580,27 @@ class DeleteGraphResponse(TypedDict): version_counts: int -@v1_router.get(path="/graphs", tags=["graphs"], dependencies=[Depends(auth_middleware)]) -async def get_graphs( - user_id: Annotated[str, Depends(get_user_id)] -) -> Sequence[graph_db.GraphModel]: - return await graph_db.get_graphs(filter_by="active", user_id=user_id) +@v1_router.get( + path="/graphs", + summary="List user graphs", + tags=["graphs"], + dependencies=[Depends(auth_middleware)], +) +async def list_graphs( + user_id: Annotated[str, Depends(get_user_id)], +) -> Sequence[graph_db.GraphMeta]: + return await graph_db.list_graphs(filter_by="active", user_id=user_id) @v1_router.get( - path="/graphs/{graph_id}", tags=["graphs"], dependencies=[Depends(auth_middleware)] + path="/graphs/{graph_id}", + summary="Get specific graph", + tags=["graphs"], + dependencies=[Depends(auth_middleware)], ) @v1_router.get( path="/graphs/{graph_id}/versions/{version}", + summary="Get graph version", tags=["graphs"], dependencies=[Depends(auth_middleware)], ) @@ -165,10 +608,14 @@ async def get_graph( graph_id: str, user_id: Annotated[str, Depends(get_user_id)], version: int | None = None, - hide_credentials: bool = False, + for_export: bool = False, ) -> graph_db.GraphModel: graph = await graph_db.get_graph( - graph_id, version, user_id=user_id, for_export=hide_credentials + graph_id, + version, + user_id=user_id, + for_export=for_export, + include_subgraphs=True, # needed to construct full credentials input schema ) if not graph: raise HTTPException(status_code=404, detail=f"Graph #{graph_id} not found.") @@ -177,14 +624,10 @@ async def get_graph( @v1_router.get( path="/graphs/{graph_id}/versions", + summary="Get all graph versions", tags=["graphs"], dependencies=[Depends(auth_middleware)], ) -@v1_router.get( - path="/templates/{graph_id}/versions", - tags=["templates", "graphs"], - dependencies=[Depends(auth_middleware)], -) async def get_graph_all_versions( graph_id: str, user_id: Annotated[str, Depends(get_user_id)] ) -> Sequence[graph_db.GraphModel]: @@ -195,75 +638,45 @@ async def get_graph_all_versions( @v1_router.post( - path="/graphs", tags=["graphs"], dependencies=[Depends(auth_middleware)] + path="/graphs", + summary="Create new graph", + tags=["graphs"], + dependencies=[Depends(auth_middleware)], ) async def create_new_graph( - create_graph: CreateGraph, user_id: Annotated[str, Depends(get_user_id)] -) -> graph_db.GraphModel: - return await do_create_graph(create_graph, is_template=False, user_id=user_id) - - -async def do_create_graph( create_graph: CreateGraph, - is_template: bool, - # user_id doesn't have to be annotated like on other endpoints, - # because create_graph isn't used directly as an endpoint - user_id: str, + user_id: Annotated[str, Depends(get_user_id)], ) -> graph_db.GraphModel: - if create_graph.graph: - graph = graph_db.make_graph_model(create_graph.graph, user_id) - elif create_graph.template_id: - # Create a new graph from a template - graph = await graph_db.get_graph( - create_graph.template_id, - create_graph.template_version, - template=True, - user_id=user_id, - ) - if not graph: - raise HTTPException( - 400, detail=f"Template #{create_graph.template_id} not found" - ) - graph.version = 1 - else: - raise HTTPException( - status_code=400, detail="Either graph or template_id must be provided." - ) - - graph.is_template = is_template - graph.is_active = not is_template + graph = graph_db.make_graph_model(create_graph.graph, user_id) graph.reassign_ids(user_id=user_id, reassign_graph_id=True) + graph.validate_graph(for_run=False) - graph = await graph_db.create_graph(graph, user_id=user_id) - graph = await on_graph_activate( - graph, - get_credentials=lambda id: integration_creds_manager.get(user_id, id), - ) - return graph + # The return value of the create graph & library function is intentionally not used here, + # as the graph already valid and no sub-graphs are returned back. + await graph_db.create_graph(graph, user_id=user_id) + await library_db.create_library_agent(graph, user_id=user_id) + return await on_graph_activate(graph, user_id=user_id) @v1_router.delete( - path="/graphs/{graph_id}", tags=["graphs"], dependencies=[Depends(auth_middleware)] + path="/graphs/{graph_id}", + summary="Delete graph permanently", + tags=["graphs"], + dependencies=[Depends(auth_middleware)], ) async def delete_graph( graph_id: str, user_id: Annotated[str, Depends(get_user_id)] ) -> DeleteGraphResponse: if active_version := await graph_db.get_graph(graph_id, user_id=user_id): - - def get_credentials(credentials_id: str) -> "Credentials | None": - return integration_creds_manager.get(user_id, credentials_id) - - await on_graph_deactivate(active_version, get_credentials) + await on_graph_deactivate(active_version, user_id=user_id) return {"version_counts": await graph_db.delete_graph(graph_id, user_id=user_id)} @v1_router.put( - path="/graphs/{graph_id}", tags=["graphs"], dependencies=[Depends(auth_middleware)] -) -@v1_router.put( - path="/templates/{graph_id}", - tags=["templates", "graphs"], + path="/graphs/{graph_id}", + summary="Update graph version", + tags=["graphs"], dependencies=[Depends(auth_middleware)], ) async def update_graph( @@ -282,46 +695,43 @@ async def update_graph( latest_version_number = max(g.version for g in existing_versions) graph.version = latest_version_number + 1 - latest_version_graph = next( - v for v in existing_versions if v.version == latest_version_number - ) current_active_version = next((v for v in existing_versions if v.is_active), None) - if latest_version_graph.is_template != graph.is_template: - raise HTTPException( - 400, detail="Changing is_template on an existing graph is forbidden" - ) - graph.is_active = not graph.is_template graph = graph_db.make_graph_model(graph, user_id) - graph.reassign_ids(user_id=user_id) + graph.reassign_ids(user_id=user_id, reassign_graph_id=False) + graph.validate_graph(for_run=False) new_graph_version = await graph_db.create_graph(graph, user_id=user_id) if new_graph_version.is_active: - - def get_credentials(credentials_id: str) -> "Credentials | None": - return integration_creds_manager.get(user_id, credentials_id) + # Keep the library agent up to date with the new active version + await library_db.update_agent_version_in_library( + user_id, graph.id, graph.version + ) # Handle activation of the new graph first to ensure continuity - new_graph_version = await on_graph_activate( - new_graph_version, - get_credentials=get_credentials, - ) + new_graph_version = await on_graph_activate(new_graph_version, user_id=user_id) # Ensure new version is the only active version await graph_db.set_graph_active_version( graph_id=graph_id, version=new_graph_version.version, user_id=user_id ) if current_active_version: # Handle deactivation of the previously active version - await on_graph_deactivate( - current_active_version, - get_credentials=get_credentials, - ) + await on_graph_deactivate(current_active_version, user_id=user_id) - return new_graph_version + # Fetch new graph version *with sub-graphs* (needed for credentials input schema) + new_graph_version_with_subgraphs = await graph_db.get_graph( + graph_id, + new_graph_version.version, + user_id=user_id, + include_subgraphs=True, + ) + assert new_graph_version_with_subgraphs # make type checker happy + return new_graph_version_with_subgraphs @v1_router.put( path="/graphs/{graph_id}/versions/active", + summary="Set active graph version", tags=["graphs"], dependencies=[Depends(auth_middleware)], ) @@ -339,134 +749,191 @@ async def set_graph_active_version( current_active_graph = await graph_db.get_graph(graph_id, user_id=user_id) - def get_credentials(credentials_id: str) -> "Credentials | None": - return integration_creds_manager.get(user_id, credentials_id) - # Handle activation of the new graph first to ensure continuity - await on_graph_activate( - new_active_graph, - get_credentials=get_credentials, - ) + await on_graph_activate(new_active_graph, user_id=user_id) # Ensure new version is the only active version await graph_db.set_graph_active_version( graph_id=graph_id, version=new_active_version, user_id=user_id, ) + + # Keep the library agent up to date with the new active version + await library_db.update_agent_version_in_library( + user_id, new_active_graph.id, new_active_graph.version + ) + if current_active_graph and current_active_graph.version != new_active_version: # Handle deactivation of the previously active version - await on_graph_deactivate( - current_active_graph, - get_credentials=get_credentials, - ) + await on_graph_deactivate(current_active_graph, user_id=user_id) @v1_router.post( - path="/graphs/{graph_id}/execute", + path="/graphs/{graph_id}/execute/{graph_version}", + summary="Execute graph agent", tags=["graphs"], dependencies=[Depends(auth_middleware)], ) -def execute_graph( +async def execute_graph( graph_id: str, - node_input: dict[Any, Any], user_id: Annotated[str, Depends(get_user_id)], -) -> dict[str, Any]: # FIXME: add proper return type + inputs: Annotated[dict[str, Any], Body(..., embed=True, default_factory=dict)], + credentials_inputs: Annotated[ + dict[str, CredentialsMetaInput], Body(..., embed=True, default_factory=dict) + ], + graph_version: Optional[int] = None, + preset_id: Optional[str] = None, +) -> ExecuteGraphResponse: + current_balance = await _user_credit_model.get_credits(user_id) + if current_balance <= 0: + raise HTTPException( + status_code=402, + detail="Insufficient balance to execute the agent. Please top up your account.", + ) + try: - graph_exec = execution_manager_client().add_execution( - graph_id, node_input, user_id=user_id + graph_exec = await execution_utils.add_graph_execution( + graph_id=graph_id, + user_id=user_id, + inputs=inputs, + preset_id=preset_id, + graph_version=graph_version, + graph_credentials_inputs=credentials_inputs, + ) + return ExecuteGraphResponse(graph_exec_id=graph_exec.id) + except GraphValidationError as e: + # Return structured validation errors that the frontend can parse + raise HTTPException( + status_code=400, + detail={ + "type": "validation_error", + "message": e.message, + # TODO: only return node-specific errors if user has access to graph + "node_errors": e.node_errors, + }, ) - return {"id": graph_exec.graph_exec_id} - except Exception as e: - msg = e.__str__().encode().decode("unicode_escape") - raise HTTPException(status_code=400, detail=msg) @v1_router.post( path="/graphs/{graph_id}/executions/{graph_exec_id}/stop", + summary="Stop graph execution", tags=["graphs"], dependencies=[Depends(auth_middleware)], ) async def stop_graph_run( - graph_exec_id: str, user_id: Annotated[str, Depends(get_user_id)] -) -> Sequence[execution_db.ExecutionResult]: - if not await graph_db.get_execution(user_id=user_id, execution_id=graph_exec_id): - raise HTTPException(404, detail=f"Agent execution #{graph_exec_id} not found") - - await asyncio.to_thread( - lambda: execution_manager_client().cancel_execution(graph_exec_id) + graph_id: str, graph_exec_id: str, user_id: Annotated[str, Depends(get_user_id)] +) -> execution_db.GraphExecutionMeta | None: + res = await _stop_graph_run( + user_id=user_id, + graph_id=graph_id, + graph_exec_id=graph_exec_id, ) + if not res: + return None + return res[0] - # Retrieve & return canceled graph execution in its final state - return await execution_db.get_execution_results(graph_exec_id) + +async def _stop_graph_run( + user_id: str, + graph_id: Optional[str] = None, + graph_exec_id: Optional[str] = None, +) -> list[execution_db.GraphExecutionMeta]: + graph_execs = await execution_db.get_graph_executions( + user_id=user_id, + graph_id=graph_id, + graph_exec_id=graph_exec_id, + statuses=[ + execution_db.ExecutionStatus.INCOMPLETE, + execution_db.ExecutionStatus.QUEUED, + execution_db.ExecutionStatus.RUNNING, + ], + ) + stopped_execs = [ + execution_utils.stop_graph_execution(graph_exec_id=exec.id, user_id=user_id) + for exec in graph_execs + ] + await asyncio.gather(*stopped_execs) + return graph_execs @v1_router.get( path="/executions", + summary="List all executions", tags=["graphs"], dependencies=[Depends(auth_middleware)], ) -async def get_executions( +async def list_graphs_executions( user_id: Annotated[str, Depends(get_user_id)], -) -> list[graph_db.GraphExecution]: - return await graph_db.get_executions(user_id=user_id) +) -> list[execution_db.GraphExecutionMeta]: + return await execution_db.get_graph_executions(user_id=user_id) @v1_router.get( - path="/graphs/{graph_id}/executions/{graph_exec_id}", + path="/graphs/{graph_id}/executions", + summary="List graph executions", tags=["graphs"], dependencies=[Depends(auth_middleware)], ) -async def get_graph_run_node_execution_results( +async def list_graph_executions( graph_id: str, - graph_exec_id: str, user_id: Annotated[str, Depends(get_user_id)], -) -> Sequence[execution_db.ExecutionResult]: - graph = await graph_db.get_graph(graph_id, user_id=user_id) - if not graph: - raise HTTPException(status_code=404, detail=f"Graph #{graph_id} not found.") - - return await execution_db.get_execution_results(graph_exec_id) - - -######################################################## -##################### Templates ######################## -######################################################## + page: int = Query(1, ge=1, description="Page number (1-indexed)"), + page_size: int = Query( + 25, ge=1, le=100, description="Number of executions per page" + ), +) -> execution_db.GraphExecutionsPaginated: + return await execution_db.get_graph_executions_paginated( + graph_id=graph_id, + user_id=user_id, + page=page, + page_size=page_size, + ) @v1_router.get( - path="/templates", - tags=["graphs", "templates"], + path="/graphs/{graph_id}/executions/{graph_exec_id}", + summary="Get execution details", + tags=["graphs"], dependencies=[Depends(auth_middleware)], ) -async def get_templates( - user_id: Annotated[str, Depends(get_user_id)] -) -> Sequence[graph_db.GraphModel]: - return await graph_db.get_graphs(filter_by="template", user_id=user_id) +async def get_graph_execution( + graph_id: str, + graph_exec_id: str, + user_id: Annotated[str, Depends(get_user_id)], +) -> execution_db.GraphExecution | execution_db.GraphExecutionWithNodes: + graph = await graph_db.get_graph(graph_id=graph_id, user_id=user_id) + if not graph: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, detail=f"Graph #{graph_id} not found" + ) + result = await execution_db.get_graph_execution( + user_id=user_id, + execution_id=graph_exec_id, + include_node_executions=graph.user_id == user_id, + ) + if not result or result.graph_id != graph_id: + raise HTTPException( + status_code=404, detail=f"Graph execution #{graph_exec_id} not found." + ) -@v1_router.get( - path="/templates/{graph_id}", - tags=["templates", "graphs"], - dependencies=[Depends(auth_middleware)], -) -async def get_template( - graph_id: str, version: int | None = None -) -> graph_db.GraphModel: - graph = await graph_db.get_graph(graph_id, version, template=True) - if not graph: - raise HTTPException(status_code=404, detail=f"Template #{graph_id} not found.") - return graph + return result -@v1_router.post( - path="/templates", - tags=["templates", "graphs"], +@v1_router.delete( + path="/executions/{graph_exec_id}", + summary="Delete graph execution", + tags=["graphs"], dependencies=[Depends(auth_middleware)], + status_code=HTTP_204_NO_CONTENT, ) -async def create_new_template( - create_graph: CreateGraph, user_id: Annotated[str, Depends(get_user_id)] -) -> graph_db.GraphModel: - return await do_create_graph(create_graph, is_template=True, user_id=user_id) +async def delete_graph_execution( + graph_exec_id: str, + user_id: Annotated[str, Depends(get_user_id)], +) -> None: + await execution_db.delete_graph_execution( + graph_exec_id=graph_exec_id, user_id=user_id + ) ######################################################## @@ -475,63 +942,138 @@ async def create_new_template( class ScheduleCreationRequest(pydantic.BaseModel): + graph_version: Optional[int] = None + name: str cron: str - input_data: dict[Any, Any] - graph_id: str + inputs: dict[str, Any] + credentials: dict[str, CredentialsMetaInput] = pydantic.Field(default_factory=dict) @v1_router.post( - path="/schedules", + path="/graphs/{graph_id}/schedules", + summary="Create execution schedule", tags=["schedules"], dependencies=[Depends(auth_middleware)], ) -async def create_schedule( +async def create_graph_execution_schedule( user_id: Annotated[str, Depends(get_user_id)], - schedule: ScheduleCreationRequest, -) -> scheduler.JobInfo: - graph = await graph_db.get_graph(schedule.graph_id, user_id=user_id) + graph_id: str = Path(..., description="ID of the graph to schedule"), + schedule_params: ScheduleCreationRequest = Body(), +) -> scheduler.GraphExecutionJobInfo: + graph = await graph_db.get_graph( + graph_id=graph_id, + version=schedule_params.graph_version, + user_id=user_id, + ) if not graph: raise HTTPException( - status_code=404, detail=f"Graph #{schedule.graph_id} not found." + status_code=404, + detail=f"Graph #{graph_id} v{schedule_params.graph_version} not found.", ) - return await asyncio.to_thread( - lambda: execution_scheduler_client().add_execution_schedule( - graph_id=schedule.graph_id, - graph_version=graph.version, - cron=schedule.cron, - input_data=schedule.input_data, - user_id=user_id, + user = await get_user_by_id(user_id) + user_timezone = get_user_timezone_or_utc(user.timezone if user else None) + + # Convert cron expression from user timezone to UTC + try: + utc_cron = convert_cron_to_utc(schedule_params.cron, user_timezone) + except ValueError as e: + raise HTTPException( + status_code=400, + detail=f"Invalid cron expression for timezone {user_timezone}: {e}", ) + + result = await get_scheduler_client().add_execution_schedule( + user_id=user_id, + graph_id=graph_id, + graph_version=graph.version, + name=schedule_params.name, + cron=utc_cron, # Send UTC cron to scheduler + input_data=schedule_params.inputs, + input_credentials=schedule_params.credentials, ) + # Convert the next_run_time back to user timezone for display + if result.next_run_time: + result.next_run_time = convert_utc_time_to_user_timezone( + result.next_run_time, user_timezone + ) -@v1_router.delete( - path="/schedules/{schedule_id}", + return result + + +@v1_router.get( + path="/graphs/{graph_id}/schedules", + summary="List execution schedules for a graph", tags=["schedules"], dependencies=[Depends(auth_middleware)], ) -def delete_schedule( - schedule_id: str, +async def list_graph_execution_schedules( user_id: Annotated[str, Depends(get_user_id)], -) -> dict[Any, Any]: - execution_scheduler_client().delete_schedule(schedule_id, user_id=user_id) - return {"id": schedule_id} + graph_id: str = Path(), +) -> list[scheduler.GraphExecutionJobInfo]: + schedules = await get_scheduler_client().get_execution_schedules( + user_id=user_id, + graph_id=graph_id, + ) + + # Get user timezone for conversion + user = await get_user_by_id(user_id) + user_timezone = get_user_timezone_or_utc(user.timezone if user else None) + + # Convert next_run_time to user timezone for display + for schedule in schedules: + if schedule.next_run_time: + schedule.next_run_time = convert_utc_time_to_user_timezone( + schedule.next_run_time, user_timezone + ) + + return schedules @v1_router.get( path="/schedules", + summary="List execution schedules for a user", tags=["schedules"], dependencies=[Depends(auth_middleware)], ) -def get_execution_schedules( +async def list_all_graphs_execution_schedules( user_id: Annotated[str, Depends(get_user_id)], - graph_id: str | None = None, -) -> list[scheduler.JobInfo]: - return execution_scheduler_client().get_execution_schedules( - user_id=user_id, - graph_id=graph_id, - ) +) -> list[scheduler.GraphExecutionJobInfo]: + schedules = await get_scheduler_client().get_execution_schedules(user_id=user_id) + + # Get user timezone for conversion + user = await get_user_by_id(user_id) + user_timezone = get_user_timezone_or_utc(user.timezone if user else None) + + # Convert UTC next_run_time to user timezone for display + for schedule in schedules: + if schedule.next_run_time: + schedule.next_run_time = convert_utc_time_to_user_timezone( + schedule.next_run_time, user_timezone + ) + + return schedules + + +@v1_router.delete( + path="/schedules/{schedule_id}", + summary="Delete execution schedule", + tags=["schedules"], + dependencies=[Depends(auth_middleware)], +) +async def delete_graph_execution_schedule( + user_id: Annotated[str, Depends(get_user_id)], + schedule_id: str = Path(..., description="ID of the schedule to delete"), +) -> dict[str, Any]: + try: + await get_scheduler_client().delete_schedule(schedule_id, user_id=user_id) + except NotFoundError: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, + detail=f"Schedule #{schedule_id} not found", + ) + return {"id": schedule_id} ######################################################## @@ -541,11 +1083,11 @@ def get_execution_schedules( @v1_router.post( "/api-keys", - response_model=list[CreateAPIKeyResponse] | dict[str, str], + summary="Create new API key", + response_model=CreateAPIKeyResponse, tags=["api-keys"], dependencies=[Depends(auth_middleware)], ) -@feature_flag("api-keys-enabled") async def create_api_key( request: CreateAPIKeyRequest, user_id: Annotated[str, Depends(get_user_id)] ) -> CreateAPIKeyResponse: @@ -559,35 +1101,45 @@ async def create_api_key( ) return CreateAPIKeyResponse(api_key=api_key, plain_text_key=plain_text) except APIKeyError as e: - logger.error(f"Failed to create API key: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) + logger.error( + "Could not create API key for user %s: %s. Review input and permissions.", + user_id, + e, + ) + raise HTTPException( + status_code=400, + detail={"message": str(e), "hint": "Verify request payload and try again."}, + ) @v1_router.get( "/api-keys", + summary="List user API keys", response_model=list[APIKeyWithoutHash] | dict[str, str], tags=["api-keys"], dependencies=[Depends(auth_middleware)], ) -@feature_flag("api-keys-enabled") async def get_api_keys( - user_id: Annotated[str, Depends(get_user_id)] + user_id: Annotated[str, Depends(get_user_id)], ) -> list[APIKeyWithoutHash]: """List all API keys for the user""" try: return await list_user_api_keys(user_id) except APIKeyError as e: - logger.error(f"Failed to list API keys: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) + logger.error("Failed to list API keys for user %s: %s", user_id, e) + raise HTTPException( + status_code=400, + detail={"message": str(e), "hint": "Check API key service availability."}, + ) @v1_router.get( "/api-keys/{key_id}", - response_model=list[APIKeyWithoutHash] | dict[str, str], + summary="Get specific API key", + response_model=APIKeyWithoutHash, tags=["api-keys"], dependencies=[Depends(auth_middleware)], ) -@feature_flag("api-keys-enabled") async def get_api_key( key_id: str, user_id: Annotated[str, Depends(get_user_id)] ) -> APIKeyWithoutHash: @@ -598,17 +1150,20 @@ async def get_api_key( raise HTTPException(status_code=404, detail="API key not found") return api_key except APIKeyError as e: - logger.error(f"Failed to get API key: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) + logger.error("Error retrieving API key %s for user %s: %s", key_id, user_id, e) + raise HTTPException( + status_code=400, + detail={"message": str(e), "hint": "Ensure the key ID is correct."}, + ) @v1_router.delete( "/api-keys/{key_id}", - response_model=list[APIKeyWithoutHash] | dict[str, str], + summary="Revoke API key", + response_model=APIKeyWithoutHash, tags=["api-keys"], dependencies=[Depends(auth_middleware)], ) -@feature_flag("api-keys-enabled") async def delete_api_key( key_id: str, user_id: Annotated[str, Depends(get_user_id)] ) -> Optional[APIKeyWithoutHash]: @@ -620,17 +1175,23 @@ async def delete_api_key( except APIKeyPermissionError: raise HTTPException(status_code=403, detail="Permission denied") except APIKeyError as e: - logger.error(f"Failed to revoke API key: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) + logger.error("Failed to revoke API key %s for user %s: %s", key_id, user_id, e) + raise HTTPException( + status_code=400, + detail={ + "message": str(e), + "hint": "Verify permissions or try again later.", + }, + ) @v1_router.post( "/api-keys/{key_id}/suspend", - response_model=list[APIKeyWithoutHash] | dict[str, str], + summary="Suspend API key", + response_model=APIKeyWithoutHash, tags=["api-keys"], dependencies=[Depends(auth_middleware)], ) -@feature_flag("api-keys-enabled") async def suspend_key( key_id: str, user_id: Annotated[str, Depends(get_user_id)] ) -> Optional[APIKeyWithoutHash]: @@ -642,17 +1203,20 @@ async def suspend_key( except APIKeyPermissionError: raise HTTPException(status_code=403, detail="Permission denied") except APIKeyError as e: - logger.error(f"Failed to suspend API key: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) + logger.error("Failed to suspend API key %s for user %s: %s", key_id, user_id, e) + raise HTTPException( + status_code=400, + detail={"message": str(e), "hint": "Check user permissions and retry."}, + ) @v1_router.put( "/api-keys/{key_id}/permissions", - response_model=list[APIKeyWithoutHash] | dict[str, str], + summary="Update key permissions", + response_model=APIKeyWithoutHash, tags=["api-keys"], dependencies=[Depends(auth_middleware)], ) -@feature_flag("api-keys-enabled") async def update_permissions( key_id: str, request: UpdatePermissionsRequest, @@ -666,5 +1230,13 @@ async def update_permissions( except APIKeyPermissionError: raise HTTPException(status_code=403, detail="Permission denied") except APIKeyError as e: - logger.error(f"Failed to update API key permissions: {str(e)}") - raise HTTPException(status_code=400, detail=str(e)) + logger.error( + "Failed to update permissions for API key %s of user %s: %s", + key_id, + user_id, + e, + ) + raise HTTPException( + status_code=400, + detail={"message": str(e), "hint": "Ensure permissions list is valid."}, + ) diff --git a/autogpt_platform/backend/backend/server/routers/v1_test.py b/autogpt_platform/backend/backend/server/routers/v1_test.py new file mode 100644 index 000000000000..eb18fc322c15 --- /dev/null +++ b/autogpt_platform/backend/backend/server/routers/v1_test.py @@ -0,0 +1,621 @@ +import json +from io import BytesIO +from unittest.mock import AsyncMock, Mock, patch + +import autogpt_libs.auth.depends +import fastapi +import fastapi.testclient +import pytest +import pytest_mock +import starlette.datastructures +from fastapi import HTTPException, UploadFile +from pytest_snapshot.plugin import Snapshot + +import backend.server.routers.v1 as v1_routes +from backend.data.credit import AutoTopUpConfig +from backend.data.graph import GraphModel +from backend.server.conftest import TEST_USER_ID +from backend.server.routers.v1 import upload_file +from backend.server.utils import get_user_id + +app = fastapi.FastAPI() +app.include_router(v1_routes.v1_router) + +client = fastapi.testclient.TestClient(app) + + +def override_auth_middleware(request: fastapi.Request) -> dict[str, str]: + """Override auth middleware for testing""" + return {"sub": TEST_USER_ID, "role": "user", "email": "test@example.com"} + + +def override_get_user_id() -> str: + """Override get_user_id for testing""" + return TEST_USER_ID + + +app.dependency_overrides[autogpt_libs.auth.middleware.auth_middleware] = ( + override_auth_middleware +) +app.dependency_overrides[get_user_id] = override_get_user_id + + +# Auth endpoints tests +def test_get_or_create_user_route( + mocker: pytest_mock.MockFixture, + configured_snapshot: Snapshot, +) -> None: + """Test get or create user endpoint""" + mock_user = Mock() + mock_user.model_dump.return_value = { + "id": TEST_USER_ID, + "email": "test@example.com", + "name": "Test User", + } + + mocker.patch( + "backend.server.routers.v1.get_or_create_user", + return_value=mock_user, + ) + + response = client.post("/auth/user") + + assert response.status_code == 200 + response_data = response.json() + + configured_snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "auth_user", + ) + + +def test_update_user_email_route( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test update user email endpoint""" + mocker.patch( + "backend.server.routers.v1.update_user_email", + return_value=None, + ) + + response = client.post("/auth/user/email", json="newemail@example.com") + + assert response.status_code == 200 + response_data = response.json() + assert response_data["email"] == "newemail@example.com" + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "auth_email", + ) + + +# Blocks endpoints tests +def test_get_graph_blocks( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test get blocks endpoint""" + # Mock block + mock_block = Mock() + mock_block.to_dict.return_value = { + "id": "test-block", + "name": "Test Block", + "description": "A test block", + "disabled": False, + } + mock_block.id = "test-block" + mock_block.disabled = False + + # Mock get_blocks + mocker.patch( + "backend.server.routers.v1.get_blocks", + return_value={"test-block": lambda: mock_block}, + ) + + # Mock block costs + mocker.patch( + "backend.server.routers.v1.get_block_costs", + return_value={"test-block": [{"cost": 10, "type": "credit"}]}, + ) + + response = client.get("/blocks") + + assert response.status_code == 200 + response_data = response.json() + assert len(response_data) == 1 + assert response_data[0]["id"] == "test-block" + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "blks_all", + ) + + +def test_execute_graph_block( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test execute block endpoint""" + # Mock block + mock_block = Mock() + + async def mock_execute(*args, **kwargs): + yield "output1", {"data": "result1"} + yield "output2", {"data": "result2"} + + mock_block.execute = mock_execute + + mocker.patch( + "backend.server.routers.v1.get_block", + return_value=mock_block, + ) + + request_data = { + "input_name": "test_input", + "input_value": "test_value", + } + + response = client.post("/blocks/test-block/execute", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "blks_exec", + ) + + +def test_execute_graph_block_not_found( + mocker: pytest_mock.MockFixture, +) -> None: + """Test execute block with non-existent block""" + mocker.patch( + "backend.server.routers.v1.get_block", + return_value=None, + ) + + response = client.post("/blocks/nonexistent-block/execute", json={}) + + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + +# Credits endpoints tests +def test_get_user_credits( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test get user credits endpoint""" + mock_credit_model = mocker.patch("backend.server.routers.v1._user_credit_model") + mock_credit_model.get_credits = AsyncMock(return_value=1000) + + response = client.get("/credits") + + assert response.status_code == 200 + response_data = response.json() + assert response_data["credits"] == 1000 + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "cred_bal", + ) + + +def test_request_top_up( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test request top up endpoint""" + mock_credit_model = mocker.patch("backend.server.routers.v1._user_credit_model") + mock_credit_model.top_up_intent = AsyncMock( + return_value="https://checkout.example.com/session123" + ) + + request_data = {"credit_amount": 500} + + response = client.post("/credits", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + assert "checkout_url" in response_data + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "cred_topup_req", + ) + + +def test_get_auto_top_up( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test get auto top-up configuration endpoint""" + mock_config = AutoTopUpConfig(threshold=100, amount=500) + + mocker.patch( + "backend.server.routers.v1.get_auto_top_up", + return_value=mock_config, + ) + + response = client.get("/credits/auto-top-up") + + assert response.status_code == 200 + response_data = response.json() + assert response_data["threshold"] == 100 + assert response_data["amount"] == 500 + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "cred_topup_cfg", + ) + + +# Graphs endpoints tests +def test_get_graphs( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test get graphs endpoint""" + mock_graph = GraphModel( + id="graph-123", + version=1, + is_active=True, + name="Test Graph", + description="A test graph", + user_id="test-user-id", + ) + + mocker.patch( + "backend.server.routers.v1.graph_db.list_graphs", + return_value=[mock_graph], + ) + + response = client.get("/graphs") + + assert response.status_code == 200 + response_data = response.json() + assert len(response_data) == 1 + assert response_data[0]["id"] == "graph-123" + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "grphs_all", + ) + + +def test_get_graph( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test get single graph endpoint""" + mock_graph = GraphModel( + id="graph-123", + version=1, + is_active=True, + name="Test Graph", + description="A test graph", + user_id="test-user-id", + ) + + mocker.patch( + "backend.server.routers.v1.graph_db.get_graph", + return_value=mock_graph, + ) + + response = client.get("/graphs/graph-123") + + assert response.status_code == 200 + response_data = response.json() + assert response_data["id"] == "graph-123" + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "grph_single", + ) + + +def test_get_graph_not_found( + mocker: pytest_mock.MockFixture, +) -> None: + """Test get graph with non-existent ID""" + mocker.patch( + "backend.server.routers.v1.graph_db.get_graph", + return_value=None, + ) + + response = client.get("/graphs/nonexistent-graph") + + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + +def test_delete_graph( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test delete graph endpoint""" + # Mock active graph for deactivation + mock_graph = GraphModel( + id="graph-123", + version=1, + is_active=True, + name="Test Graph", + description="A test graph", + user_id="test-user-id", + ) + + mocker.patch( + "backend.server.routers.v1.graph_db.get_graph", + return_value=mock_graph, + ) + mocker.patch( + "backend.server.routers.v1.on_graph_deactivate", + return_value=None, + ) + mocker.patch( + "backend.server.routers.v1.graph_db.delete_graph", + return_value=3, # Number of versions deleted + ) + + response = client.delete("/graphs/graph-123") + + assert response.status_code == 200 + response_data = response.json() + assert response_data["version_counts"] == 3 + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "grphs_del", + ) + + +# Invalid request tests +def test_invalid_json_request() -> None: + """Test endpoint with invalid JSON""" + response = client.post( + "/auth/user/email", + content="invalid json", + headers={"Content-Type": "application/json"}, + ) + assert response.status_code == 422 + + +def test_missing_required_field() -> None: + """Test endpoint with missing required field""" + response = client.post("/credits", json={}) # Missing credit_amount + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_upload_file_success(): + """Test successful file upload.""" + # Create mock upload file + file_content = b"test file content" + file_obj = BytesIO(file_content) + upload_file_mock = UploadFile( + filename="test.txt", + file=file_obj, + headers=starlette.datastructures.Headers({"content-type": "text/plain"}), + ) + + # Mock dependencies + with patch("backend.server.routers.v1.scan_content_safe") as mock_scan, patch( + "backend.server.routers.v1.get_cloud_storage_handler" + ) as mock_handler_getter: + + mock_scan.return_value = None + mock_handler = AsyncMock() + mock_handler.store_file.return_value = "gcs://test-bucket/uploads/123/test.txt" + mock_handler_getter.return_value = mock_handler + + # Mock file.read() + upload_file_mock.read = AsyncMock(return_value=file_content) + + result = await upload_file( + file=upload_file_mock, + user_id="test-user-123", + provider="gcs", + expiration_hours=24, + ) + + # Verify result + assert result.file_uri == "gcs://test-bucket/uploads/123/test.txt" + assert result.file_name == "test.txt" + assert result.size == len(file_content) + assert result.content_type == "text/plain" + assert result.expires_in_hours == 24 + + # Verify virus scan was called + mock_scan.assert_called_once_with(file_content, filename="test.txt") + + # Verify cloud storage operations + mock_handler.store_file.assert_called_once_with( + content=file_content, + filename="test.txt", + provider="gcs", + expiration_hours=24, + user_id="test-user-123", + ) + + +@pytest.mark.asyncio +async def test_upload_file_no_filename(): + """Test file upload without filename.""" + file_content = b"test content" + file_obj = BytesIO(file_content) + upload_file_mock = UploadFile( + filename=None, + file=file_obj, + headers=starlette.datastructures.Headers( + {"content-type": "application/octet-stream"} + ), + ) + + with patch("backend.server.routers.v1.scan_content_safe") as mock_scan, patch( + "backend.server.routers.v1.get_cloud_storage_handler" + ) as mock_handler_getter: + + mock_scan.return_value = None + mock_handler = AsyncMock() + mock_handler.store_file.return_value = ( + "gcs://test-bucket/uploads/123/uploaded_file" + ) + mock_handler_getter.return_value = mock_handler + + upload_file_mock.read = AsyncMock(return_value=file_content) + + result = await upload_file(file=upload_file_mock, user_id="test-user-123") + + assert result.file_name == "uploaded_file" + assert result.content_type == "application/octet-stream" + + # Verify virus scan was called with default filename + mock_scan.assert_called_once_with(file_content, filename="uploaded_file") + + +@pytest.mark.asyncio +async def test_upload_file_invalid_expiration(): + """Test file upload with invalid expiration hours.""" + file_obj = BytesIO(b"content") + upload_file_mock = UploadFile( + filename="test.txt", + file=file_obj, + headers=starlette.datastructures.Headers({"content-type": "text/plain"}), + ) + + # Test expiration too short + with pytest.raises(HTTPException) as exc_info: + await upload_file( + file=upload_file_mock, user_id="test-user-123", expiration_hours=0 + ) + assert exc_info.value.status_code == 400 + assert "between 1 and 48" in exc_info.value.detail + + # Test expiration too long + with pytest.raises(HTTPException) as exc_info: + await upload_file( + file=upload_file_mock, user_id="test-user-123", expiration_hours=49 + ) + assert exc_info.value.status_code == 400 + assert "between 1 and 48" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_upload_file_virus_scan_failure(): + """Test file upload when virus scan fails.""" + file_content = b"malicious content" + file_obj = BytesIO(file_content) + upload_file_mock = UploadFile( + filename="virus.txt", + file=file_obj, + headers=starlette.datastructures.Headers({"content-type": "text/plain"}), + ) + + with patch("backend.server.routers.v1.scan_content_safe") as mock_scan: + # Mock virus scan to raise exception + mock_scan.side_effect = RuntimeError("Virus detected!") + + upload_file_mock.read = AsyncMock(return_value=file_content) + + with pytest.raises(RuntimeError, match="Virus detected!"): + await upload_file(file=upload_file_mock, user_id="test-user-123") + + +@pytest.mark.asyncio +async def test_upload_file_cloud_storage_failure(): + """Test file upload when cloud storage fails.""" + file_content = b"test content" + file_obj = BytesIO(file_content) + upload_file_mock = UploadFile( + filename="test.txt", + file=file_obj, + headers=starlette.datastructures.Headers({"content-type": "text/plain"}), + ) + + with patch("backend.server.routers.v1.scan_content_safe") as mock_scan, patch( + "backend.server.routers.v1.get_cloud_storage_handler" + ) as mock_handler_getter: + + mock_scan.return_value = None + mock_handler = AsyncMock() + mock_handler.store_file.side_effect = RuntimeError("Storage error!") + mock_handler_getter.return_value = mock_handler + + upload_file_mock.read = AsyncMock(return_value=file_content) + + with pytest.raises(RuntimeError, match="Storage error!"): + await upload_file(file=upload_file_mock, user_id="test-user-123") + + +@pytest.mark.asyncio +async def test_upload_file_size_limit_exceeded(): + """Test file upload when file size exceeds the limit.""" + # Create a file that exceeds the default 256MB limit + large_file_content = b"x" * (257 * 1024 * 1024) # 257MB + file_obj = BytesIO(large_file_content) + upload_file_mock = UploadFile( + filename="large_file.txt", + file=file_obj, + headers=starlette.datastructures.Headers({"content-type": "text/plain"}), + ) + + upload_file_mock.read = AsyncMock(return_value=large_file_content) + + with pytest.raises(HTTPException) as exc_info: + await upload_file(file=upload_file_mock, user_id="test-user-123") + + assert exc_info.value.status_code == 400 + assert "exceeds the maximum allowed size of 256MB" in exc_info.value.detail + + +@pytest.mark.asyncio +async def test_upload_file_gcs_not_configured_fallback(): + """Test file upload fallback to base64 when GCS is not configured.""" + file_content = b"test file content" + file_obj = BytesIO(file_content) + upload_file_mock = UploadFile( + filename="test.txt", + file=file_obj, + headers=starlette.datastructures.Headers({"content-type": "text/plain"}), + ) + + with patch("backend.server.routers.v1.scan_content_safe") as mock_scan, patch( + "backend.server.routers.v1.get_cloud_storage_handler" + ) as mock_handler_getter: + + mock_scan.return_value = None + mock_handler = AsyncMock() + mock_handler.config.gcs_bucket_name = "" # Simulate no GCS bucket configured + mock_handler_getter.return_value = mock_handler + + upload_file_mock.read = AsyncMock(return_value=file_content) + + result = await upload_file(file=upload_file_mock, user_id="test-user-123") + + # Verify fallback behavior + assert result.file_name == "test.txt" + assert result.size == len(file_content) + assert result.content_type == "text/plain" + assert result.expires_in_hours == 24 + + # Verify file_uri is base64 data URI + expected_data_uri = "data:text/plain;base64,dGVzdCBmaWxlIGNvbnRlbnQ=" + assert result.file_uri == expected_data_uri + + # Verify virus scan was called + mock_scan.assert_called_once_with(file_content, filename="test.txt") + + # Verify cloud storage methods were NOT called + mock_handler.store_file.assert_not_called() diff --git a/autogpt_platform/backend/backend/server/test_fixtures.py b/autogpt_platform/backend/backend/server/test_fixtures.py new file mode 100644 index 000000000000..f787724649aa --- /dev/null +++ b/autogpt_platform/backend/backend/server/test_fixtures.py @@ -0,0 +1,144 @@ +"""Common test fixtures with proper setup and teardown.""" + +from contextlib import asynccontextmanager +from typing import AsyncGenerator +from unittest.mock import Mock, patch + +import pytest +from prisma import Prisma + + +@pytest.fixture +async def test_db_connection() -> AsyncGenerator[Prisma, None]: + """Provide a test database connection with proper cleanup. + + This fixture ensures the database connection is properly + closed after the test, even if the test fails. + """ + db = Prisma() + try: + await db.connect() + yield db + finally: + await db.disconnect() + + +@pytest.fixture +def mock_transaction(): + """Mock database transaction with proper async context manager.""" + + @asynccontextmanager + async def mock_context(*args, **kwargs): + yield None + + with patch("backend.data.db.locked_transaction", side_effect=mock_context) as mock: + yield mock + + +@pytest.fixture +def isolated_app_state(): + """Fixture that ensures app state is isolated between tests.""" + # Example: Save original state + # from backend.server.app import app + # original_overrides = app.dependency_overrides.copy() + + # try: + # yield app + # finally: + # # Restore original state + # app.dependency_overrides = original_overrides + + # For now, just yield None as this is an example + yield None + + +@pytest.fixture +def cleanup_files(): + """Fixture to track and cleanup files created during tests.""" + created_files = [] + + def track_file(filepath: str): + created_files.append(filepath) + + yield track_file + + # Cleanup + import os + + for filepath in created_files: + try: + if os.path.exists(filepath): + os.remove(filepath) + except Exception as e: + print(f"Warning: Failed to cleanup {filepath}: {e}") + + +@pytest.fixture +async def async_mock_with_cleanup(): + """Create async mocks that are properly cleaned up.""" + mocks = [] + + def create_mock(**kwargs): + mock = Mock(**kwargs) + mocks.append(mock) + return mock + + yield create_mock + + # Reset all mocks + for mock in mocks: + mock.reset_mock() + + +class TestDatabaseIsolation: + """Example of proper test isolation with database operations.""" + + @pytest.fixture(autouse=True) + async def setup_and_teardown(self, test_db_connection): + """Setup and teardown for each test method.""" + # Setup: Clear test data + await test_db_connection.user.delete_many( + where={"email": {"contains": "@test.example"}} + ) + + yield + + # Teardown: Clear test data again + await test_db_connection.user.delete_many( + where={"email": {"contains": "@test.example"}} + ) + + @pytest.fixture(scope="session") + async def test_create_user(self, test_db_connection): + """Test that demonstrates proper isolation.""" + # This test has access to a clean database + user = await test_db_connection.user.create( + data={ + "id": "test-user-id", + "email": "test@test.example", + "name": "Test User", + } + ) + assert user.email == "test@test.example" + # User will be cleaned up automatically + + +@pytest.fixture(scope="function") # Explicitly use function scope +def reset_singleton_state(): + """Reset singleton state between tests.""" + # Example: Reset a singleton instance + # from backend.data.some_singleton import SingletonClass + + # # Save original state + # original_instance = getattr(SingletonClass, "_instance", None) + + # try: + # # Clear singleton + # SingletonClass._instance = None + # yield + # finally: + # # Restore original state + # SingletonClass._instance = original_instance + + # For now, just yield None as this is an example + yield None diff --git a/autogpt_platform/backend/backend/server/test_helpers.py b/autogpt_platform/backend/backend/server/test_helpers.py new file mode 100644 index 000000000000..98073f0992f8 --- /dev/null +++ b/autogpt_platform/backend/backend/server/test_helpers.py @@ -0,0 +1,109 @@ +"""Helper functions for improved test assertions and error handling.""" + +import json +from typing import Any, Dict, Optional + + +def assert_response_status( + response: Any, expected_status: int = 200, error_context: Optional[str] = None +) -> None: + """Assert response status with helpful error message. + + Args: + response: The HTTP response object + expected_status: Expected status code + error_context: Optional context to include in error message + """ + if response.status_code != expected_status: + error_msg = f"Expected status {expected_status}, got {response.status_code}" + if error_context: + error_msg = f"{error_context}: {error_msg}" + + # Try to include response body in error + try: + body = response.json() + error_msg += f"\nResponse body: {json.dumps(body, indent=2)}" + except Exception: + error_msg += f"\nResponse text: {response.text}" + + raise AssertionError(error_msg) + + +def safe_parse_json( + response: Any, error_context: Optional[str] = None +) -> Dict[str, Any]: + """Safely parse JSON response with error handling. + + Args: + response: The HTTP response object + error_context: Optional context for error messages + + Returns: + Parsed JSON data + + Raises: + AssertionError: If JSON parsing fails + """ + try: + return response.json() + except Exception as e: + error_msg = f"Failed to parse JSON response: {e}" + if error_context: + error_msg = f"{error_context}: {error_msg}" + error_msg += f"\nResponse text: {response.text[:500]}" + raise AssertionError(error_msg) + + +def assert_error_response_structure( + response: Any, + expected_status: int = 422, + expected_error_fields: Optional[list[str]] = None, +) -> Dict[str, Any]: + """Assert error response has expected structure. + + Args: + response: The HTTP response object + expected_status: Expected error status code + expected_error_fields: List of expected fields in error detail + + Returns: + Parsed error response + """ + assert_response_status(response, expected_status, "Error response check") + + error_data = safe_parse_json(response, "Error response parsing") + + # Check basic error structure + assert "detail" in error_data, f"Missing 'detail' in error response: {error_data}" + + # Check specific error fields if provided + if expected_error_fields: + detail = error_data["detail"] + if isinstance(detail, list): + # FastAPI validation errors + for error in detail: + assert "loc" in error, f"Missing 'loc' in error: {error}" + assert "msg" in error, f"Missing 'msg' in error: {error}" + assert "type" in error, f"Missing 'type' in error: {error}" + + return error_data + + +def assert_mock_called_with_partial(mock_obj: Any, **expected_kwargs: Any) -> None: + """Assert mock was called with expected kwargs (partial match). + + Args: + mock_obj: The mock object to check + **expected_kwargs: Expected keyword arguments + """ + assert mock_obj.called, f"Mock {mock_obj} was not called" + + actual_kwargs = mock_obj.call_args.kwargs if mock_obj.call_args else {} + + for key, expected_value in expected_kwargs.items(): + assert ( + key in actual_kwargs + ), f"Missing key '{key}' in mock call. Actual keys: {list(actual_kwargs.keys())}" + assert ( + actual_kwargs[key] == expected_value + ), f"Mock called with {key}={actual_kwargs[key]}, expected {expected_value}" diff --git a/autogpt_platform/backend/backend/server/test_utils.py b/autogpt_platform/backend/backend/server/test_utils.py new file mode 100644 index 000000000000..567e9d5e5c7d --- /dev/null +++ b/autogpt_platform/backend/backend/server/test_utils.py @@ -0,0 +1,74 @@ +"""Common test utilities and constants for server tests.""" + +from typing import Any, Dict +from unittest.mock import Mock + +import pytest + +# Test ID constants +TEST_USER_ID = "test-user-id" +ADMIN_USER_ID = "admin-user-id" +TARGET_USER_ID = "target-user-id" + +# Common test data constants +FIXED_TIMESTAMP = "2024-01-01T00:00:00Z" +TRANSACTION_UUID = "transaction-123-uuid" +METRIC_UUID = "metric-123-uuid" +ANALYTICS_UUID = "analytics-123-uuid" + + +def create_mock_with_id(mock_id: str) -> Mock: + """Create a mock object with an id attribute. + + Args: + mock_id: The ID value to set on the mock + + Returns: + Mock object with id attribute set + """ + return Mock(id=mock_id) + + +def assert_status_and_parse_json( + response: Any, expected_status: int = 200 +) -> Dict[str, Any]: + """Assert response status and return parsed JSON. + + Args: + response: The HTTP response object + expected_status: Expected status code (default: 200) + + Returns: + Parsed JSON response data + + Raises: + AssertionError: If status code doesn't match expected + """ + assert ( + response.status_code == expected_status + ), f"Expected status {expected_status}, got {response.status_code}: {response.text}" + return response.json() + + +@pytest.mark.parametrize( + "metric_value,metric_name,data_string", + [ + (100, "api_calls_count", "external_api"), + (0, "error_count", "no_errors"), + (-5.2, "temperature_delta", "cooling"), + (1.23456789, "precision_test", "float_precision"), + (999999999, "large_number", "max_value"), + ], +) +def parametrized_metric_values_decorator(func): + """Decorator for parametrized metric value tests.""" + return pytest.mark.parametrize( + "metric_value,metric_name,data_string", + [ + (100, "api_calls_count", "external_api"), + (0, "error_count", "no_errors"), + (-5.2, "temperature_delta", "cooling"), + (1.23456789, "precision_test", "float_precision"), + (999999999, "large_number", "max_value"), + ], + )(func) diff --git a/autogpt_platform/backend/backend/server/v2/AutoMod/__init__.py b/autogpt_platform/backend/backend/server/v2/AutoMod/__init__.py new file mode 100644 index 000000000000..c721d22efa35 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/AutoMod/__init__.py @@ -0,0 +1 @@ +# AutoMod integration for content moderation diff --git a/autogpt_platform/backend/backend/server/v2/AutoMod/manager.py b/autogpt_platform/backend/backend/server/v2/AutoMod/manager.py new file mode 100644 index 000000000000..181fcec24875 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/AutoMod/manager.py @@ -0,0 +1,364 @@ +import asyncio +import json +import logging +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from backend.executor import DatabaseManagerAsyncClient + +from pydantic import ValidationError + +from backend.data.execution import ExecutionStatus +from backend.server.v2.AutoMod.models import ( + AutoModRequest, + AutoModResponse, + ModerationConfig, +) +from backend.util.exceptions import ModerationError +from backend.util.feature_flag import Flag, is_feature_enabled +from backend.util.request import Requests +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) + + +class AutoModManager: + + def __init__(self): + self.config = self._load_config() + + def _load_config(self) -> ModerationConfig: + """Load AutoMod configuration from settings""" + settings = Settings() + return ModerationConfig( + enabled=settings.config.automod_enabled, + api_url=settings.config.automod_api_url, + api_key=settings.secrets.automod_api_key, + timeout=settings.config.automod_timeout, + retry_attempts=settings.config.automod_retry_attempts, + retry_delay=settings.config.automod_retry_delay, + fail_open=settings.config.automod_fail_open, + ) + + async def moderate_graph_execution_inputs( + self, db_client: "DatabaseManagerAsyncClient", graph_exec, timeout: int = 10 + ) -> Exception | None: + """ + Complete input moderation flow for graph execution + Returns: error_if_failed (None means success) + """ + if not self.config.enabled: + return None + + # Check if AutoMod feature is enabled for this user + if not await is_feature_enabled(Flag.AUTOMOD, graph_exec.user_id): + logger.debug(f"AutoMod feature not enabled for user {graph_exec.user_id}") + return None + + # Get graph model and collect all inputs + graph_model = await db_client.get_graph( + graph_exec.graph_id, + user_id=graph_exec.user_id, + version=graph_exec.graph_version, + ) + + if not graph_model or not graph_model.nodes: + return None + + all_inputs = [] + for node in graph_model.nodes: + if node.input_default: + all_inputs.extend(str(v) for v in node.input_default.values() if v) + if (masks := graph_exec.nodes_input_masks) and (mask := masks.get(node.id)): + all_inputs.extend(str(v) for v in mask.values() if v) + + if not all_inputs: + return None + + # Combine all content and moderate directly + content = " ".join(all_inputs) + + # Run moderation + logger.warning( + f"Moderating inputs for graph execution {graph_exec.graph_exec_id}" + ) + try: + moderation_passed, content_id = await self._moderate_content( + content, + { + "user_id": graph_exec.user_id, + "graph_id": graph_exec.graph_id, + "graph_exec_id": graph_exec.graph_exec_id, + "moderation_type": "execution_input", + }, + ) + + if not moderation_passed: + logger.warning( + f"Moderation failed for graph execution {graph_exec.graph_exec_id}" + ) + # Update node statuses for frontend display before raising error + await self._update_failed_nodes_for_moderation( + db_client, graph_exec.graph_exec_id, "input", content_id + ) + + return ModerationError( + message="Execution failed due to input content moderation", + user_id=graph_exec.user_id, + graph_exec_id=graph_exec.graph_exec_id, + moderation_type="input", + content_id=content_id, + ) + + return None + + except asyncio.TimeoutError: + logger.warning( + f"Input moderation timed out for graph execution {graph_exec.graph_exec_id}, bypassing moderation" + ) + return None # Bypass moderation on timeout + except Exception as e: + logger.warning(f"Input moderation execution failed: {e}") + return ModerationError( + message="Execution failed due to input content moderation error", + user_id=graph_exec.user_id, + graph_exec_id=graph_exec.graph_exec_id, + moderation_type="input", + ) + + async def moderate_graph_execution_outputs( + self, + db_client: "DatabaseManagerAsyncClient", + graph_exec_id: str, + user_id: str, + graph_id: str, + timeout: int = 10, + ) -> Exception | None: + """ + Complete output moderation flow for graph execution + Returns: error_if_failed (None means success) + """ + if not self.config.enabled: + return None + + # Check if AutoMod feature is enabled for this user + if not await is_feature_enabled(Flag.AUTOMOD, user_id): + logger.debug(f"AutoMod feature not enabled for user {user_id}") + return None + + # Get completed executions and collect outputs + completed_executions = await db_client.get_node_executions( + graph_exec_id, statuses=[ExecutionStatus.COMPLETED], include_exec_data=True + ) + + if not completed_executions: + return None + + all_outputs = [] + for exec_entry in completed_executions: + if exec_entry.output_data: + all_outputs.extend(str(v) for v in exec_entry.output_data.values() if v) + + if not all_outputs: + return None + + # Combine all content and moderate directly + content = " ".join(all_outputs) + + # Run moderation + logger.warning(f"Moderating outputs for graph execution {graph_exec_id}") + try: + moderation_passed, content_id = await self._moderate_content( + content, + { + "user_id": user_id, + "graph_id": graph_id, + "graph_exec_id": graph_exec_id, + "moderation_type": "execution_output", + }, + ) + + if not moderation_passed: + logger.warning(f"Moderation failed for graph execution {graph_exec_id}") + # Update node statuses for frontend display before raising error + await self._update_failed_nodes_for_moderation( + db_client, graph_exec_id, "output", content_id + ) + + return ModerationError( + message="Execution failed due to output content moderation", + user_id=user_id, + graph_exec_id=graph_exec_id, + moderation_type="output", + content_id=content_id, + ) + + return None + + except asyncio.TimeoutError: + logger.warning( + f"Output moderation timed out for graph execution {graph_exec_id}, bypassing moderation" + ) + return None # Bypass moderation on timeout + except Exception as e: + logger.warning(f"Output moderation execution failed: {e}") + return ModerationError( + message="Execution failed due to output content moderation error", + user_id=user_id, + graph_exec_id=graph_exec_id, + moderation_type="output", + ) + + async def _update_failed_nodes_for_moderation( + self, + db_client: "DatabaseManagerAsyncClient", + graph_exec_id: str, + moderation_type: Literal["input", "output"], + content_id: str | None = None, + ): + """Update node execution statuses for frontend display when moderation fails""" + # Import here to avoid circular imports + from backend.executor.manager import send_async_execution_update + + if moderation_type == "input": + # For input moderation, mark queued/running/incomplete nodes as failed + target_statuses = [ + ExecutionStatus.QUEUED, + ExecutionStatus.RUNNING, + ExecutionStatus.INCOMPLETE, + ] + else: + # For output moderation, mark completed nodes as failed + target_statuses = [ExecutionStatus.COMPLETED] + + # Get the executions that need to be updated + executions_to_update = await db_client.get_node_executions( + graph_exec_id, statuses=target_statuses, include_exec_data=True + ) + + if not executions_to_update: + return + + # Create error message with content_id if available + error_message = "Failed due to content moderation" + if content_id: + error_message += f" (Moderation ID: {content_id})" + + # Prepare database update tasks + exec_updates = [] + for exec_entry in executions_to_update: + # Collect all input and output names to clear + cleared_inputs = {} + cleared_outputs = {} + + if exec_entry.input_data: + for name in exec_entry.input_data.keys(): + cleared_inputs[name] = [error_message] + + if exec_entry.output_data: + for name in exec_entry.output_data.keys(): + cleared_outputs[name] = [error_message] + + # Add update task to list + exec_updates.append( + db_client.update_node_execution_status( + exec_entry.node_exec_id, + status=ExecutionStatus.FAILED, + stats={ + "error": error_message, + "cleared_inputs": cleared_inputs, + "cleared_outputs": cleared_outputs, + }, + ) + ) + + # Execute all database updates in parallel + updated_execs = await asyncio.gather(*exec_updates) + + # Send all websocket updates in parallel + await asyncio.gather( + *[ + send_async_execution_update(updated_exec) + for updated_exec in updated_execs + ] + ) + + async def _moderate_content( + self, content: str, metadata: dict[str, Any] + ) -> tuple[bool, str | None]: + """Moderate content using AutoMod API + + Returns: + Tuple of (approval_status, content_id) + - approval_status: True if approved or timeout occurred, False if rejected + - content_id: Reference ID from moderation API, or None if not available + + Raises: + asyncio.TimeoutError: When moderation times out (should be bypassed) + """ + try: + request_data = AutoModRequest( + type="text", + content=content, + metadata=metadata, + ) + + response = await self._make_request(request_data) + + if response.success and response.status == "approved": + logger.debug( + f"Content approved for {metadata.get('graph_exec_id', 'unknown')}" + ) + return True, response.content_id + else: + reasons = [r.reason for r in response.moderation_results if r.reason] + error_msg = f"Content rejected by AutoMod: {'; '.join(reasons)}" + logger.warning(f"Content rejected: {error_msg}") + return False, response.content_id + + except asyncio.TimeoutError: + # Re-raise timeout to be handled by calling methods + logger.warning( + f"AutoMod API timeout for {metadata.get('graph_exec_id', 'unknown')}" + ) + raise + except Exception as e: + logger.error(f"AutoMod moderation error: {e}") + return self.config.fail_open, None + + async def _make_request(self, request_data: AutoModRequest) -> AutoModResponse: + """Make HTTP request to AutoMod API using the standard request utility""" + url = f"{self.config.api_url}/moderate" + headers = { + "Content-Type": "application/json", + "X-API-Key": self.config.api_key.strip(), + } + + # Create requests instance with timeout and retry configuration + requests = Requests( + extra_headers=headers, + retry_max_wait=float(self.config.timeout), + ) + + try: + response = await requests.post( + url, json=request_data.model_dump(), timeout=self.config.timeout + ) + + response_data = response.json() + return AutoModResponse.model_validate(response_data) + + except asyncio.TimeoutError: + # Re-raise timeout error to be caught by _moderate_content + raise + except (json.JSONDecodeError, ValidationError) as e: + raise Exception(f"Invalid response from AutoMod API: {e}") + except Exception as e: + # Check if this is an aiohttp timeout that we should convert + if "timeout" in str(e).lower(): + raise asyncio.TimeoutError(f"AutoMod API request timed out: {e}") + raise Exception(f"AutoMod API request failed: {e}") + + +# Global instance +automod_manager = AutoModManager() diff --git a/autogpt_platform/backend/backend/server/v2/AutoMod/models.py b/autogpt_platform/backend/backend/server/v2/AutoMod/models.py new file mode 100644 index 000000000000..23bd9fe87f6e --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/AutoMod/models.py @@ -0,0 +1,60 @@ +from typing import Any, Dict, List, Optional + +from pydantic import BaseModel, Field + + +class AutoModRequest(BaseModel): + """Request model for AutoMod API""" + + type: str = Field(..., description="Content type - 'text', 'image', 'video'") + content: str = Field(..., description="The content to moderate") + metadata: Optional[Dict[str, Any]] = Field( + default=None, description="Additional context about the content" + ) + + +class ModerationResult(BaseModel): + """Individual moderation result""" + + decision: str = Field( + ..., description="Moderation decision: 'approved', 'rejected', 'flagged'" + ) + reason: Optional[str] = Field(default=None, description="Reason for the decision") + + +class AutoModResponse(BaseModel): + """Response model for AutoMod API""" + + success: bool = Field(..., description="Whether the request was successful") + content_id: str = Field( + ..., description="Unique reference ID for this moderation request" + ) + status: str = Field( + ..., description="Overall status: 'approved', 'rejected', 'flagged', 'pending'" + ) + moderation_results: List[ModerationResult] = Field( + default_factory=list, description="List of moderation results" + ) + + +class ModerationConfig(BaseModel): + """Configuration for AutoMod integration""" + + enabled: bool = Field(default=True, description="Whether moderation is enabled") + api_url: str = Field(default="", description="AutoMod API base URL") + api_key: str = Field(..., description="AutoMod API key") + timeout: int = Field(default=30, description="Request timeout in seconds") + retry_attempts: int = Field(default=3, description="Number of retry attempts") + retry_delay: float = Field( + default=1.0, description="Delay between retries in seconds" + ) + fail_open: bool = Field( + default=False, + description="If True, allow execution to continue if moderation fails", + ) + moderate_inputs: bool = Field( + default=True, description="Whether to moderate block inputs" + ) + moderate_outputs: bool = Field( + default=True, description="Whether to moderate block outputs" + ) diff --git a/autogpt_platform/backend/backend/server/v2/admin/credit_admin_routes.py b/autogpt_platform/backend/backend/server/v2/admin/credit_admin_routes.py new file mode 100644 index 000000000000..36e05f12984a --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/admin/credit_admin_routes.py @@ -0,0 +1,80 @@ +import logging +import typing + +from autogpt_libs.auth import requires_admin_user +from autogpt_libs.auth.depends import get_user_id +from fastapi import APIRouter, Body, Depends +from prisma.enums import CreditTransactionType + +from backend.data.credit import admin_get_user_history, get_user_credit_model +from backend.server.v2.admin.model import AddUserCreditsResponse, UserHistoryResponse +from backend.util.json import SafeJson + +logger = logging.getLogger(__name__) + +_user_credit_model = get_user_credit_model() + + +router = APIRouter( + prefix="/admin", + tags=["credits", "admin"], + dependencies=[Depends(requires_admin_user)], +) + + +@router.post( + "/add_credits", response_model=AddUserCreditsResponse, summary="Add Credits to User" +) +async def add_user_credits( + user_id: typing.Annotated[str, Body()], + amount: typing.Annotated[int, Body()], + comments: typing.Annotated[str, Body()], + admin_user: typing.Annotated[ + str, + Depends(get_user_id), + ], +): + """ """ + logger.info(f"Admin user {admin_user} is adding {amount} credits to user {user_id}") + new_balance, transaction_key = await _user_credit_model._add_transaction( + user_id, + amount, + transaction_type=CreditTransactionType.GRANT, + metadata=SafeJson({"admin_id": admin_user, "reason": comments}), + ) + return { + "new_balance": new_balance, + "transaction_key": transaction_key, + } + + +@router.get( + "/users_history", + response_model=UserHistoryResponse, + summary="Get All Users History", +) +async def admin_get_all_user_history( + admin_user: typing.Annotated[ + str, + Depends(get_user_id), + ], + search: typing.Optional[str] = None, + page: int = 1, + page_size: int = 20, + transaction_filter: typing.Optional[CreditTransactionType] = None, +): + """ """ + logger.info(f"Admin user {admin_user} is getting grant history") + + try: + resp = await admin_get_user_history( + page=page, + page_size=page_size, + search=search, + transaction_filter=transaction_filter, + ) + logger.info(f"Admin user {admin_user} got {len(resp.history)} grant history") + return resp + except Exception as e: + logger.exception(f"Error getting grant history: {e}") + raise e diff --git a/autogpt_platform/backend/backend/server/v2/admin/credit_admin_routes_test.py b/autogpt_platform/backend/backend/server/v2/admin/credit_admin_routes_test.py new file mode 100644 index 000000000000..e970d9069c45 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/admin/credit_admin_routes_test.py @@ -0,0 +1,331 @@ +import json +from unittest.mock import AsyncMock + +import autogpt_libs.auth +import autogpt_libs.auth.depends +import fastapi +import fastapi.testclient +import prisma.enums +import pytest_mock +from prisma import Json +from pytest_snapshot.plugin import Snapshot + +import backend.server.v2.admin.credit_admin_routes as credit_admin_routes +import backend.server.v2.admin.model as admin_model +from backend.data.model import UserTransaction +from backend.server.conftest import ADMIN_USER_ID, TARGET_USER_ID +from backend.util.models import Pagination + +app = fastapi.FastAPI() +app.include_router(credit_admin_routes.router) + +client = fastapi.testclient.TestClient(app) + + +def override_requires_admin_user() -> dict[str, str]: + """Override admin user check for testing""" + return {"sub": ADMIN_USER_ID, "role": "admin"} + + +def override_get_user_id() -> str: + """Override get_user_id for testing""" + return ADMIN_USER_ID + + +app.dependency_overrides[autogpt_libs.auth.requires_admin_user] = ( + override_requires_admin_user +) +app.dependency_overrides[autogpt_libs.auth.depends.get_user_id] = override_get_user_id + + +def test_add_user_credits_success( + mocker: pytest_mock.MockFixture, + configured_snapshot: Snapshot, +) -> None: + """Test successful credit addition by admin""" + # Mock the credit model + mock_credit_model = mocker.patch( + "backend.server.v2.admin.credit_admin_routes._user_credit_model" + ) + mock_credit_model._add_transaction = AsyncMock( + return_value=(1500, "transaction-123-uuid") + ) + + request_data = { + "user_id": TARGET_USER_ID, + "amount": 500, + "comments": "Test credit grant for debugging", + } + + response = client.post("/admin/add_credits", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["new_balance"] == 1500 + assert response_data["transaction_key"] == "transaction-123-uuid" + + # Verify the function was called with correct parameters + mock_credit_model._add_transaction.assert_called_once() + call_args = mock_credit_model._add_transaction.call_args + assert call_args[0] == (TARGET_USER_ID, 500) + assert call_args[1]["transaction_type"] == prisma.enums.CreditTransactionType.GRANT + # Check that metadata is a Json object with the expected content + assert isinstance(call_args[1]["metadata"], Json) + assert call_args[1]["metadata"] == Json( + {"admin_id": ADMIN_USER_ID, "reason": "Test credit grant for debugging"} + ) + + # Snapshot test the response + configured_snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "admin_add_credits_success", + ) + + +def test_add_user_credits_negative_amount( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test credit deduction by admin (negative amount)""" + # Mock the credit model + mock_credit_model = mocker.patch( + "backend.server.v2.admin.credit_admin_routes._user_credit_model" + ) + mock_credit_model._add_transaction = AsyncMock( + return_value=(200, "transaction-456-uuid") + ) + + request_data = { + "user_id": "target-user-id", + "amount": -100, + "comments": "Refund adjustment", + } + + response = client.post("/admin/add_credits", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["new_balance"] == 200 + + # Snapshot test the response + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "adm_add_cred_neg", + ) + + +def test_get_user_history_success( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test successful retrieval of user credit history""" + # Mock the admin_get_user_history function + mock_history_response = admin_model.UserHistoryResponse( + history=[ + UserTransaction( + user_id="user-1", + user_email="user1@example.com", + amount=1000, + reason="Initial grant", + transaction_type=prisma.enums.CreditTransactionType.GRANT, + ), + UserTransaction( + user_id="user-2", + user_email="user2@example.com", + amount=-50, + reason="Usage", + transaction_type=prisma.enums.CreditTransactionType.USAGE, + ), + ], + pagination=Pagination( + total_items=2, + total_pages=1, + current_page=1, + page_size=20, + ), + ) + + mocker.patch( + "backend.server.v2.admin.credit_admin_routes.admin_get_user_history", + return_value=mock_history_response, + ) + + response = client.get("/admin/users_history") + + assert response.status_code == 200 + response_data = response.json() + assert len(response_data["history"]) == 2 + assert response_data["pagination"]["total_items"] == 2 + + # Snapshot test the response + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "adm_usr_hist_ok", + ) + + +def test_get_user_history_with_filters( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test user credit history with search and filter parameters""" + # Mock the admin_get_user_history function + mock_history_response = admin_model.UserHistoryResponse( + history=[ + UserTransaction( + user_id="user-3", + user_email="test@example.com", + amount=500, + reason="Top up", + transaction_type=prisma.enums.CreditTransactionType.TOP_UP, + ), + ], + pagination=Pagination( + total_items=1, + total_pages=1, + current_page=1, + page_size=10, + ), + ) + + mock_get_history = mocker.patch( + "backend.server.v2.admin.credit_admin_routes.admin_get_user_history", + return_value=mock_history_response, + ) + + response = client.get( + "/admin/users_history", + params={ + "search": "test@example.com", + "page": 1, + "page_size": 10, + "transaction_filter": "TOP_UP", + }, + ) + + assert response.status_code == 200 + response_data = response.json() + assert len(response_data["history"]) == 1 + assert response_data["history"][0]["transaction_type"] == "TOP_UP" + + # Verify the function was called with correct parameters + mock_get_history.assert_called_once_with( + page=1, + page_size=10, + search="test@example.com", + transaction_filter=prisma.enums.CreditTransactionType.TOP_UP, + ) + + # Snapshot test the response + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "adm_usr_hist_filt", + ) + + +def test_get_user_history_empty_results( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test user credit history with no results""" + # Mock empty history response + mock_history_response = admin_model.UserHistoryResponse( + history=[], + pagination=Pagination( + total_items=0, + total_pages=0, + current_page=1, + page_size=20, + ), + ) + + mocker.patch( + "backend.server.v2.admin.credit_admin_routes.admin_get_user_history", + return_value=mock_history_response, + ) + + response = client.get("/admin/users_history", params={"search": "nonexistent"}) + + assert response.status_code == 200 + response_data = response.json() + assert len(response_data["history"]) == 0 + assert response_data["pagination"]["total_items"] == 0 + + # Snapshot test the response + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "adm_usr_hist_empty", + ) + + +def test_add_credits_invalid_request() -> None: + """Test credit addition with invalid request data""" + # Missing required fields + response = client.post("/admin/add_credits", json={}) + assert response.status_code == 422 + + # Invalid amount type + response = client.post( + "/admin/add_credits", + json={ + "user_id": "test", + "amount": "not_a_number", + "comments": "test", + }, + ) + assert response.status_code == 422 + + # Missing comments + response = client.post( + "/admin/add_credits", + json={ + "user_id": "test", + "amount": 100, + }, + ) + assert response.status_code == 422 + + +def test_admin_endpoints_require_admin_role(mocker: pytest_mock.MockFixture) -> None: + """Test that admin endpoints require admin role""" + # Clear the admin override to test authorization + app.dependency_overrides.clear() + + # Mock requires_admin_user to raise an exception + mocker.patch( + "autogpt_libs.auth.requires_admin_user", + side_effect=fastapi.HTTPException( + status_code=403, detail="Admin access required" + ), + ) + + # Test add_credits endpoint + response = client.post( + "/admin/add_credits", + json={ + "user_id": "test", + "amount": 100, + "comments": "test", + }, + ) + assert ( + response.status_code == 401 + ) # Auth middleware returns 401 when auth is disabled + + # Test users_history endpoint + response = client.get("/admin/users_history") + assert ( + response.status_code == 401 + ) # Auth middleware returns 401 when auth is disabled + + # Restore the override + app.dependency_overrides[autogpt_libs.auth.requires_admin_user] = ( + override_requires_admin_user + ) + app.dependency_overrides[autogpt_libs.auth.depends.get_user_id] = ( + override_get_user_id + ) diff --git a/autogpt_platform/backend/backend/server/v2/admin/model.py b/autogpt_platform/backend/backend/server/v2/admin/model.py new file mode 100644 index 000000000000..82f51e8e7ac8 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/admin/model.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel + +from backend.data.model import UserTransaction +from backend.util.models import Pagination + + +class UserHistoryResponse(BaseModel): + """Response model for listings with version history""" + + history: list[UserTransaction] + pagination: Pagination + + +class AddUserCreditsResponse(BaseModel): + new_balance: int + transaction_key: str diff --git a/autogpt_platform/backend/backend/server/v2/admin/store_admin_routes.py b/autogpt_platform/backend/backend/server/v2/admin/store_admin_routes.py new file mode 100644 index 000000000000..f37f83294805 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/admin/store_admin_routes.py @@ -0,0 +1,149 @@ +import logging +import tempfile +import typing + +import autogpt_libs.auth.depends +import fastapi +import fastapi.responses +import prisma.enums + +import backend.server.v2.store.db +import backend.server.v2.store.exceptions +import backend.server.v2.store.model +import backend.util.json + +logger = logging.getLogger(__name__) + +router = fastapi.APIRouter(prefix="/admin", tags=["store", "admin"]) + + +@router.get( + "/listings", + summary="Get Admin Listings History", + response_model=backend.server.v2.store.model.StoreListingsWithVersionsResponse, + dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)], +) +async def get_admin_listings_with_versions( + status: typing.Optional[prisma.enums.SubmissionStatus] = None, + search: typing.Optional[str] = None, + page: int = 1, + page_size: int = 20, +): + """ + Get store listings with their version history for admins. + + This provides a consolidated view of listings with their versions, + allowing for an expandable UI in the admin dashboard. + + Args: + status: Filter by submission status (PENDING, APPROVED, REJECTED) + search: Search by name, description, or user email + page: Page number for pagination + page_size: Number of items per page + + Returns: + StoreListingsWithVersionsResponse with listings and their versions + """ + try: + listings = await backend.server.v2.store.db.get_admin_listings_with_versions( + status=status, + search_query=search, + page=page, + page_size=page_size, + ) + return listings + except Exception as e: + logger.exception("Error getting admin listings with versions: %s", e) + return fastapi.responses.JSONResponse( + status_code=500, + content={ + "detail": "An error occurred while retrieving listings with versions" + }, + ) + + +@router.post( + "/submissions/{store_listing_version_id}/review", + summary="Review Store Submission", + response_model=backend.server.v2.store.model.StoreSubmission, + dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)], +) +async def review_submission( + store_listing_version_id: str, + request: backend.server.v2.store.model.ReviewSubmissionRequest, + user: typing.Annotated[ + autogpt_libs.auth.models.User, + fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user), + ], +): + """ + Review a store listing submission. + + Args: + store_listing_version_id: ID of the submission to review + request: Review details including approval status and comments + user: Authenticated admin user performing the review + + Returns: + StoreSubmission with updated review information + """ + try: + submission = await backend.server.v2.store.db.review_store_submission( + store_listing_version_id=store_listing_version_id, + is_approved=request.is_approved, + external_comments=request.comments, + internal_comments=request.internal_comments or "", + reviewer_id=user.user_id, + ) + return submission + except Exception as e: + logger.exception("Error reviewing submission: %s", e) + return fastapi.responses.JSONResponse( + status_code=500, + content={"detail": "An error occurred while reviewing the submission"}, + ) + + +@router.get( + "/submissions/download/{store_listing_version_id}", + summary="Admin Download Agent File", + tags=["store", "admin"], + dependencies=[fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user)], +) +async def admin_download_agent_file( + user: typing.Annotated[ + autogpt_libs.auth.models.User, + fastapi.Depends(autogpt_libs.auth.depends.requires_admin_user), + ], + store_listing_version_id: str = fastapi.Path( + ..., description="The ID of the agent to download" + ), +) -> fastapi.responses.FileResponse: + """ + Download the agent file by streaming its content. + + Args: + store_listing_version_id (str): The ID of the agent to download + + Returns: + StreamingResponse: A streaming response containing the agent's graph data. + + Raises: + HTTPException: If the agent is not found or an unexpected error occurs. + """ + graph_data = await backend.server.v2.store.db.get_agent_as_admin( + user_id=user.user_id, + store_listing_version_id=store_listing_version_id, + ) + file_name = f"agent_{graph_data.id}_v{graph_data.version or 'latest'}.json" + + # Sending graph as a stream (similar to marketplace v1) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as tmp_file: + tmp_file.write(backend.util.json.dumps(graph_data)) + tmp_file.flush() + + return fastapi.responses.FileResponse( + tmp_file.name, filename=file_name, media_type="application/json" + ) diff --git a/autogpt_platform/backend/backend/server/v2/builder/db.py b/autogpt_platform/backend/backend/server/v2/builder/db.py new file mode 100644 index 000000000000..3cef2655615c --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/builder/db.py @@ -0,0 +1,376 @@ +import functools +import logging +from datetime import datetime, timedelta, timezone + +import prisma + +import backend.data.block +from backend.blocks import load_all_blocks +from backend.blocks.llm import LlmModel +from backend.data.block import Block, BlockCategory, BlockSchema +from backend.data.credit import get_block_costs +from backend.integrations.providers import ProviderName +from backend.server.v2.builder.model import ( + BlockCategoryResponse, + BlockData, + BlockResponse, + BlockType, + CountResponse, + Provider, + ProviderResponse, + SearchBlocksResponse, +) +from backend.util.models import Pagination + +logger = logging.getLogger(__name__) +llm_models = [name.name.lower().replace("_", " ") for name in LlmModel] +_static_counts_cache: dict | None = None +_suggested_blocks: list[BlockData] | None = None + + +def get_block_categories(category_blocks: int = 3) -> list[BlockCategoryResponse]: + categories: dict[BlockCategory, BlockCategoryResponse] = {} + + for block_type in load_all_blocks().values(): + block: Block[BlockSchema, BlockSchema] = block_type() + # Skip disabled blocks + if block.disabled: + continue + # Skip blocks that don't have categories (all should have at least one) + if not block.categories: + continue + + # Add block to the categories + for category in block.categories: + if category not in categories: + categories[category] = BlockCategoryResponse( + name=category.name.lower(), + total_blocks=0, + blocks=[], + ) + + categories[category].total_blocks += 1 + + # Append if the category has less than the specified number of blocks + if len(categories[category].blocks) < category_blocks: + categories[category].blocks.append(block.to_dict()) + + # Sort categories by name + return sorted(categories.values(), key=lambda x: x.name) + + +def get_blocks( + *, + category: str | None = None, + type: BlockType | None = None, + provider: ProviderName | None = None, + page: int = 1, + page_size: int = 50, +) -> BlockResponse: + """ + Get blocks based on either category, type or provider. + Providing nothing fetches all block types. + """ + # Only one of category, type, or provider can be specified + if (category and type) or (category and provider) or (type and provider): + raise ValueError("Only one of category, type, or provider can be specified") + + blocks: list[Block[BlockSchema, BlockSchema]] = [] + skip = (page - 1) * page_size + take = page_size + total = 0 + + for block_type in load_all_blocks().values(): + block: Block[BlockSchema, BlockSchema] = block_type() + # Skip disabled blocks + if block.disabled: + continue + # Skip blocks that don't match the category + if category and category not in {c.name.lower() for c in block.categories}: + continue + # Skip blocks that don't match the type + if ( + (type == "input" and block.block_type.value != "Input") + or (type == "output" and block.block_type.value != "Output") + or (type == "action" and block.block_type.value in ("Input", "Output")) + ): + continue + # Skip blocks that don't match the provider + if provider: + credentials_info = block.input_schema.get_credentials_fields_info().values() + if not any(provider in info.provider for info in credentials_info): + continue + + total += 1 + if skip > 0: + skip -= 1 + continue + if take > 0: + take -= 1 + blocks.append(block) + + costs = get_block_costs() + + return BlockResponse( + blocks=[{**b.to_dict(), "costs": costs.get(b.id, [])} for b in blocks], + pagination=Pagination( + total_items=total, + total_pages=(total + page_size - 1) // page_size, + current_page=page, + page_size=page_size, + ), + ) + + +def search_blocks( + include_blocks: bool = True, + include_integrations: bool = True, + query: str = "", + page: int = 1, + page_size: int = 50, +) -> SearchBlocksResponse: + """ + Get blocks based on the filter and query. + `providers` only applies for `integrations` filter. + """ + blocks: list[Block[BlockSchema, BlockSchema]] = [] + query = query.lower() + + total = 0 + skip = (page - 1) * page_size + take = page_size + block_count = 0 + integration_count = 0 + + for block_type in load_all_blocks().values(): + block: Block[BlockSchema, BlockSchema] = block_type() + # Skip disabled blocks + if block.disabled: + continue + # Skip blocks that don't match the query + if ( + query not in block.name.lower() + and query not in block.description.lower() + and not _matches_llm_model(block.input_schema, query) + ): + continue + keep = False + credentials = list(block.input_schema.get_credentials_fields().values()) + if include_integrations and len(credentials) > 0: + keep = True + integration_count += 1 + if include_blocks and len(credentials) == 0: + keep = True + block_count += 1 + + if not keep: + continue + + total += 1 + if skip > 0: + skip -= 1 + continue + if take > 0: + take -= 1 + blocks.append(block) + + costs = get_block_costs() + + return SearchBlocksResponse( + blocks=BlockResponse( + blocks=[{**b.to_dict(), "costs": costs.get(b.id, [])} for b in blocks], + pagination=Pagination( + total_items=total, + total_pages=(total + page_size - 1) // page_size, + current_page=page, + page_size=page_size, + ), + ), + total_block_count=block_count, + total_integration_count=integration_count, + ) + + +def get_providers( + query: str = "", + page: int = 1, + page_size: int = 50, +) -> ProviderResponse: + providers = [] + query = query.lower() + + skip = (page - 1) * page_size + take = page_size + + all_providers = _get_all_providers() + + for provider in all_providers.values(): + if ( + query not in provider.name.value.lower() + and query not in provider.description.lower() + ): + continue + if skip > 0: + skip -= 1 + continue + if take > 0: + take -= 1 + providers.append(provider) + + total = len(all_providers) + + return ProviderResponse( + providers=providers, + pagination=Pagination( + total_items=total, + total_pages=(total + page_size - 1) // page_size, + current_page=page, + page_size=page_size, + ), + ) + + +async def get_counts(user_id: str) -> CountResponse: + my_agents = await prisma.models.LibraryAgent.prisma().count( + where={ + "userId": user_id, + "isDeleted": False, + "isArchived": False, + } + ) + counts = await _get_static_counts() + return CountResponse( + my_agents=my_agents, + **counts, + ) + + +async def _get_static_counts(): + """ + Get counts of blocks, integrations, and marketplace agents. + This is cached to avoid unnecessary database queries and calculations. + Can't use functools.cache here because the function is async. + """ + global _static_counts_cache + if _static_counts_cache is not None: + return _static_counts_cache + + all_blocks = 0 + input_blocks = 0 + action_blocks = 0 + output_blocks = 0 + integrations = 0 + + for block_type in load_all_blocks().values(): + block: Block[BlockSchema, BlockSchema] = block_type() + if block.disabled: + continue + + all_blocks += 1 + + if block.block_type.value == "Input": + input_blocks += 1 + elif block.block_type.value == "Output": + output_blocks += 1 + else: + action_blocks += 1 + + credentials = list(block.input_schema.get_credentials_fields().values()) + if len(credentials) > 0: + integrations += 1 + + marketplace_agents = await prisma.models.StoreAgent.prisma().count() + + _static_counts_cache = { + "all_blocks": all_blocks, + "input_blocks": input_blocks, + "action_blocks": action_blocks, + "output_blocks": output_blocks, + "integrations": integrations, + "marketplace_agents": marketplace_agents, + } + + return _static_counts_cache + + +def _matches_llm_model(schema_cls: type[BlockSchema], query: str) -> bool: + for field in schema_cls.model_fields.values(): + if field.annotation == LlmModel: + # Check if query matches any value in llm_models + if any(query in name for name in llm_models): + return True + return False + + +@functools.cache +def _get_all_providers() -> dict[ProviderName, Provider]: + providers: dict[ProviderName, Provider] = {} + + for block_type in load_all_blocks().values(): + block: Block[BlockSchema, BlockSchema] = block_type() + if block.disabled: + continue + + credentials_info = block.input_schema.get_credentials_fields_info().values() + for info in credentials_info: + for provider in info.provider: # provider is a ProviderName enum member + if provider in providers: + providers[provider].integration_count += 1 + else: + providers[provider] = Provider( + name=provider, description="", integration_count=1 + ) + return providers + + +async def get_suggested_blocks(count: int = 5) -> list[BlockData]: + global _suggested_blocks + + if _suggested_blocks is not None and len(_suggested_blocks) >= count: + return _suggested_blocks[:count] + + _suggested_blocks = [] + # Sum the number of executions for each block type + # Prisma cannot group by nested relations, so we do a raw query + # Calculate the cutoff timestamp + timestamp_threshold = datetime.now(timezone.utc) - timedelta(days=30) + + results = await prisma.get_client().query_raw( + """ + SELECT + agent_node."agentBlockId" AS block_id, + COUNT(execution.id) AS execution_count + FROM "AgentNodeExecution" execution + JOIN "AgentNode" agent_node ON execution."agentNodeId" = agent_node.id + WHERE execution."endedTime" >= $1::timestamp + GROUP BY agent_node."agentBlockId" + ORDER BY execution_count DESC; + """, + timestamp_threshold, + ) + + # Get the top blocks based on execution count + # But ignore Input and Output blocks + blocks: list[tuple[BlockData, int]] = [] + + for block_type in load_all_blocks().values(): + block: Block[BlockSchema, BlockSchema] = block_type() + if block.disabled or block.block_type in ( + backend.data.block.BlockType.INPUT, + backend.data.block.BlockType.OUTPUT, + backend.data.block.BlockType.AGENT, + ): + continue + # Find the execution count for this block + execution_count = next( + (row["execution_count"] for row in results if row["block_id"] == block.id), + 0, + ) + blocks.append((block.to_dict(), execution_count)) + # Sort blocks by execution count + blocks.sort(key=lambda x: x[1], reverse=True) + + _suggested_blocks = [block[0] for block in blocks] + + # Return the top blocks + return _suggested_blocks[:count] diff --git a/autogpt_platform/backend/backend/server/v2/builder/model.py b/autogpt_platform/backend/backend/server/v2/builder/model.py new file mode 100644 index 000000000000..7b9fe58f0a22 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/builder/model.py @@ -0,0 +1,87 @@ +from typing import Any, Literal + +from pydantic import BaseModel + +import backend.server.v2.library.model as library_model +import backend.server.v2.store.model as store_model +from backend.integrations.providers import ProviderName +from backend.util.models import Pagination + +FilterType = Literal[ + "blocks", + "integrations", + "marketplace_agents", + "my_agents", +] + +BlockType = Literal["all", "input", "action", "output"] + +BlockData = dict[str, Any] + + +# Suggestions +class SuggestionsResponse(BaseModel): + otto_suggestions: list[str] + recent_searches: list[str] + providers: list[ProviderName] + top_blocks: list[BlockData] + + +# All blocks +class BlockCategoryResponse(BaseModel): + name: str + total_blocks: int + blocks: list[BlockData] + + model_config = {"use_enum_values": False} # <== use enum names like "AI" + + +# Input/Action/Output and see all for block categories +class BlockResponse(BaseModel): + blocks: list[BlockData] + pagination: Pagination + + +# Providers +class Provider(BaseModel): + name: ProviderName + description: str + integration_count: int + + +class ProviderResponse(BaseModel): + providers: list[Provider] + pagination: Pagination + + +# Search +class SearchRequest(BaseModel): + search_query: str | None = None + filter: list[FilterType] | None = None + by_creator: list[str] | None = None + search_id: str | None = None + page: int | None = None + page_size: int | None = None + + +class SearchBlocksResponse(BaseModel): + blocks: BlockResponse + total_block_count: int + total_integration_count: int + + +class SearchResponse(BaseModel): + items: list[BlockData | library_model.LibraryAgent | store_model.StoreAgent] + total_items: dict[FilterType, int] + page: int + more_pages: bool + + +class CountResponse(BaseModel): + all_blocks: int + input_blocks: int + action_blocks: int + output_blocks: int + integrations: int + marketplace_agents: int + my_agents: int diff --git a/autogpt_platform/backend/backend/server/v2/builder/routes.py b/autogpt_platform/backend/backend/server/v2/builder/routes.py new file mode 100644 index 000000000000..0e78af3d4b9f --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/builder/routes.py @@ -0,0 +1,239 @@ +import logging +from typing import Annotated, Sequence + +import fastapi +from autogpt_libs.auth.depends import auth_middleware, get_user_id + +import backend.server.v2.builder.db as builder_db +import backend.server.v2.builder.model as builder_model +import backend.server.v2.library.db as library_db +import backend.server.v2.library.model as library_model +import backend.server.v2.store.db as store_db +import backend.server.v2.store.model as store_model +from backend.integrations.providers import ProviderName +from backend.util.models import Pagination + +logger = logging.getLogger(__name__) + +router = fastapi.APIRouter() + + +# Taken from backend/server/v2/store/db.py +def sanitize_query(query: str | None) -> str | None: + if query is None: + return query + query = query.strip()[:100] + return ( + query.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + .replace("[", "\\[") + .replace("]", "\\]") + .replace("'", "\\'") + .replace('"', '\\"') + .replace(";", "\\;") + .replace("--", "\\--") + .replace("/*", "\\/*") + .replace("*/", "\\*/") + ) + + +@router.get( + "/suggestions", + summary="Get Builder suggestions", + dependencies=[fastapi.Depends(auth_middleware)], + response_model=builder_model.SuggestionsResponse, +) +async def get_suggestions( + user_id: Annotated[str, fastapi.Depends(get_user_id)], +) -> builder_model.SuggestionsResponse: + """ + Get all suggestions for the Blocks Menu. + """ + return builder_model.SuggestionsResponse( + otto_suggestions=[ + "What blocks do I need to get started?", + "Help me create a list", + "Help me feed my data to Google Maps", + ], + recent_searches=[ + "image generation", + "deepfake", + "competitor analysis", + ], + providers=[ + ProviderName.TWITTER, + ProviderName.GITHUB, + ProviderName.NOTION, + ProviderName.GOOGLE, + ProviderName.DISCORD, + ProviderName.GOOGLE_MAPS, + ], + top_blocks=await builder_db.get_suggested_blocks(), + ) + + +@router.get( + "/categories", + summary="Get Builder block categories", + dependencies=[fastapi.Depends(auth_middleware)], + response_model=Sequence[builder_model.BlockCategoryResponse], +) +async def get_block_categories( + blocks_per_category: Annotated[int, fastapi.Query()] = 3, +) -> Sequence[builder_model.BlockCategoryResponse]: + """ + Get all block categories with a specified number of blocks per category. + """ + return builder_db.get_block_categories(blocks_per_category) + + +@router.get( + "/blocks", + summary="Get Builder blocks", + dependencies=[fastapi.Depends(auth_middleware)], + response_model=builder_model.BlockResponse, +) +async def get_blocks( + category: Annotated[str | None, fastapi.Query()] = None, + type: Annotated[builder_model.BlockType | None, fastapi.Query()] = None, + provider: Annotated[ProviderName | None, fastapi.Query()] = None, + page: Annotated[int, fastapi.Query()] = 1, + page_size: Annotated[int, fastapi.Query()] = 50, +) -> builder_model.BlockResponse: + """ + Get blocks based on either category, type, or provider. + """ + return builder_db.get_blocks( + category=category, + type=type, + provider=provider, + page=page, + page_size=page_size, + ) + + +@router.get( + "/providers", + summary="Get Builder integration providers", + dependencies=[fastapi.Depends(auth_middleware)], + response_model=builder_model.ProviderResponse, +) +async def get_providers( + page: Annotated[int, fastapi.Query()] = 1, + page_size: Annotated[int, fastapi.Query()] = 50, +) -> builder_model.ProviderResponse: + """ + Get all integration providers with their block counts. + """ + return builder_db.get_providers( + page=page, + page_size=page_size, + ) + + +@router.post( + "/search", + summary="Builder search", + tags=["store", "private"], + dependencies=[fastapi.Depends(auth_middleware)], + response_model=builder_model.SearchResponse, +) +async def search( + options: builder_model.SearchRequest, + user_id: Annotated[str, fastapi.Depends(get_user_id)], +) -> builder_model.SearchResponse: + """ + Search for blocks (including integrations), marketplace agents, and user library agents. + """ + # If no filters are provided, then we will return all types + if not options.filter: + options.filter = [ + "blocks", + "integrations", + "marketplace_agents", + "my_agents", + ] + options.search_query = sanitize_query(options.search_query) + options.page = options.page or 1 + options.page_size = options.page_size or 50 + + # Blocks&Integrations + blocks = builder_model.SearchBlocksResponse( + blocks=builder_model.BlockResponse( + blocks=[], + pagination=Pagination.empty(), + ), + total_block_count=0, + total_integration_count=0, + ) + if "blocks" in options.filter or "integrations" in options.filter: + blocks = builder_db.search_blocks( + include_blocks="blocks" in options.filter, + include_integrations="integrations" in options.filter, + query=options.search_query or "", + page=options.page, + page_size=options.page_size, + ) + + # Library Agents + my_agents = library_model.LibraryAgentResponse( + agents=[], + pagination=Pagination.empty(), + ) + if "my_agents" in options.filter: + my_agents = await library_db.list_library_agents( + user_id=user_id, + search_term=options.search_query, + page=options.page, + page_size=options.page_size, + ) + + # Marketplace Agents + marketplace_agents = store_model.StoreAgentsResponse( + agents=[], + pagination=Pagination.empty(), + ) + if "marketplace_agents" in options.filter: + marketplace_agents = await store_db.get_store_agents( + creators=options.by_creator, + search_query=options.search_query, + page=options.page, + page_size=options.page_size, + ) + + more_pages = False + if ( + blocks.blocks.pagination.current_page < blocks.blocks.pagination.total_pages + or my_agents.pagination.current_page < my_agents.pagination.total_pages + or marketplace_agents.pagination.current_page + < marketplace_agents.pagination.total_pages + ): + more_pages = True + + return builder_model.SearchResponse( + items=blocks.blocks.blocks + my_agents.agents + marketplace_agents.agents, + total_items={ + "blocks": blocks.total_block_count, + "integrations": blocks.total_integration_count, + "marketplace_agents": marketplace_agents.pagination.total_items, + "my_agents": my_agents.pagination.total_items, + }, + page=options.page, + more_pages=more_pages, + ) + + +@router.get( + "/counts", + summary="Get Builder item counts", + dependencies=[fastapi.Depends(auth_middleware)], + response_model=builder_model.CountResponse, +) +async def get_counts( + user_id: Annotated[str, fastapi.Depends(get_user_id)], +) -> builder_model.CountResponse: + """ + Get item counts for the menu categories in the Blocks Menu. + """ + return await builder_db.get_counts(user_id) diff --git a/autogpt_platform/backend/backend/server/v2/library/db.py b/autogpt_platform/backend/backend/server/v2/library/db.py index 8d142ef40c76..9c69b023a0cf 100644 --- a/autogpt_platform/backend/backend/server/v2/library/db.py +++ b/autogpt_platform/backend/backend/server/v2/library/db.py @@ -1,165 +1,932 @@ +import asyncio import logging -from typing import List +from typing import Literal, Optional +import fastapi import prisma.errors +import prisma.fields import prisma.models import prisma.types -import backend.data.graph -import backend.data.includes -import backend.server.v2.library.model -import backend.server.v2.store.exceptions +import backend.data.graph as graph_db +import backend.server.v2.library.model as library_model +import backend.server.v2.store.exceptions as store_exceptions +import backend.server.v2.store.image_gen as store_image_gen +import backend.server.v2.store.media as store_media +from backend.data.block import BlockInput +from backend.data.db import transaction +from backend.data.execution import get_graph_execution +from backend.data.includes import library_agent_include +from backend.data.model import CredentialsMetaInput +from backend.integrations.creds_manager import IntegrationCredentialsManager +from backend.integrations.webhooks.graph_lifecycle_hooks import on_graph_activate +from backend.util.exceptions import NotFoundError +from backend.util.json import SafeJson +from backend.util.models import Pagination +from backend.util.settings import Config logger = logging.getLogger(__name__) +config = Config() +integration_creds_manager = IntegrationCredentialsManager() -async def get_library_agents( +async def list_library_agents( user_id: str, -) -> List[backend.server.v2.library.model.LibraryAgent]: + search_term: Optional[str] = None, + sort_by: library_model.LibraryAgentSort = library_model.LibraryAgentSort.UPDATED_AT, + page: int = 1, + page_size: int = 50, +) -> library_model.LibraryAgentResponse: """ - Returns all agents (AgentGraph) that belong to the user and all agents in their library (UserAgent table) + Retrieves a paginated list of LibraryAgent records for a given user. + + Args: + user_id: The ID of the user whose LibraryAgents we want to retrieve. + search_term: Optional string to filter agents by name/description. + sort_by: Sorting field (createdAt, updatedAt, isFavorite, isCreatedByUser). + page: Current page (1-indexed). + page_size: Number of items per page. + + Returns: + A LibraryAgentResponse containing the list of agents and pagination details. + + Raises: + DatabaseError: If there is an issue fetching from Prisma. """ - logger.debug(f"Getting library agents for user {user_id}") + logger.debug( + f"Fetching library agents for user_id={user_id}, " + f"search_term={repr(search_term)}, " + f"sort_by={sort_by}, page={page}, page_size={page_size}" + ) - try: - # Get agents created by user with nodes and links - user_created = await prisma.models.AgentGraph.prisma().find_many( - where=prisma.types.AgentGraphWhereInput(userId=user_id, isActive=True), - include=backend.data.includes.AGENT_GRAPH_INCLUDE, - ) + if page < 1 or page_size < 1: + logger.warning(f"Invalid pagination: page={page}, page_size={page_size}") + raise store_exceptions.DatabaseError("Invalid pagination input") - # Get agents in user's library with nodes and links - library_agents = await prisma.models.UserAgent.prisma().find_many( - where=prisma.types.UserAgentWhereInput( - userId=user_id, isDeleted=False, isArchived=False - ), - include={ - "Agent": { - "include": { - "AgentNodes": { - "include": { - "Input": True, - "Output": True, - "Webhook": True, - "AgentBlock": True, - } - } + if search_term and len(search_term.strip()) > 100: + logger.warning(f"Search term too long: {repr(search_term)}") + raise store_exceptions.DatabaseError("Search term is too long") + + where_clause: prisma.types.LibraryAgentWhereInput = { + "userId": user_id, + "isDeleted": False, + "isArchived": False, + } + + # Build search filter if applicable + if search_term: + where_clause["OR"] = [ + { + "AgentGraph": { + "is": {"name": {"contains": search_term, "mode": "insensitive"}} + } + }, + { + "AgentGraph": { + "is": { + "description": {"contains": search_term, "mode": "insensitive"} } } }, + ] + + # Determine sorting + order_by: prisma.types.LibraryAgentOrderByInput | None = None + + if sort_by == library_model.LibraryAgentSort.CREATED_AT: + order_by = {"createdAt": "asc"} + elif sort_by == library_model.LibraryAgentSort.UPDATED_AT: + order_by = {"updatedAt": "desc"} + + try: + library_agents = await prisma.models.LibraryAgent.prisma().find_many( + where=where_clause, + include=library_agent_include(user_id), + order=order_by, + skip=(page - 1) * page_size, + take=page_size, + ) + agent_count = await prisma.models.LibraryAgent.prisma().count( + where=where_clause + ) + + logger.debug( + f"Retrieved {len(library_agents)} library agents for user #{user_id}" ) - # Convert to Graph models first - graphs = [] + # Only pass valid agents to the response + valid_library_agents: list[library_model.LibraryAgent] = [] - # Add user created agents - for agent in user_created: + for agent in library_agents: try: - graphs.append(backend.data.graph.GraphModel.from_db(agent)) + library_agent = library_model.LibraryAgent.from_db(agent) + valid_library_agents.append(library_agent) except Exception as e: - logger.error(f"Error processing user created agent {agent.id}: {e}") + # Skip this agent if there was an error + logger.error( + f"Error parsing LibraryAgent #{agent.id} from DB item: {e}" + ) continue - # Add library agents - for agent in library_agents: - if agent.Agent: - try: - graphs.append(backend.data.graph.GraphModel.from_db(agent.Agent)) - except Exception as e: - logger.error(f"Error processing library agent {agent.agentId}: {e}") - continue - - # Convert Graph models to LibraryAgent models - result = [] - for graph in graphs: - result.append( - backend.server.v2.library.model.LibraryAgent( - id=graph.id, - version=graph.version, - is_active=graph.is_active, - name=graph.name, - description=graph.description, - isCreatedByUser=any(a.id == graph.id for a in user_created), - input_schema=graph.input_schema, - output_schema=graph.output_schema, + # Return the response with only valid agents + return library_model.LibraryAgentResponse( + agents=valid_library_agents, + pagination=Pagination( + total_items=agent_count, + total_pages=(agent_count + page_size - 1) // page_size, + current_page=page, + page_size=page_size, + ), + ) + + except prisma.errors.PrismaError as e: + logger.error(f"Database error fetching library agents: {e}") + raise store_exceptions.DatabaseError("Failed to fetch library agents") from e + + +async def get_library_agent(id: str, user_id: str) -> library_model.LibraryAgent: + """ + Get a specific agent from the user's library. + + Args: + id: ID of the library agent to retrieve. + user_id: ID of the authenticated user. + + Returns: + The requested LibraryAgent. + + Raises: + AgentNotFoundError: If the specified agent does not exist. + DatabaseError: If there's an error during retrieval. + """ + try: + library_agent = await prisma.models.LibraryAgent.prisma().find_first( + where={ + "id": id, + "userId": user_id, + "isDeleted": False, + }, + include=library_agent_include(user_id), + ) + + if not library_agent: + raise NotFoundError(f"Library agent #{id} not found") + + return library_model.LibraryAgent.from_db( + library_agent, + sub_graphs=( + await graph_db.get_sub_graphs(library_agent.AgentGraph) + if library_agent.AgentGraph + else None + ), + ) + + except prisma.errors.PrismaError as e: + logger.error(f"Database error fetching library agent: {e}") + raise store_exceptions.DatabaseError("Failed to fetch library agent") from e + + +async def get_library_agent_by_store_version_id( + store_listing_version_id: str, + user_id: str, +) -> library_model.LibraryAgent | None: + """ + Get the library agent metadata for a given store listing version ID and user ID. + """ + logger.debug( + f"Getting library agent for store listing ID: {store_listing_version_id}" + ) + + store_listing_version = ( + await prisma.models.StoreListingVersion.prisma().find_unique( + where={"id": store_listing_version_id}, + ) + ) + if not store_listing_version: + logger.warning(f"Store listing version not found: {store_listing_version_id}") + raise NotFoundError( + f"Store listing version {store_listing_version_id} not found or invalid" + ) + + # Check if user already has this agent + agent = await prisma.models.LibraryAgent.prisma().find_first( + where={ + "userId": user_id, + "agentGraphId": store_listing_version.agentGraphId, + "agentGraphVersion": store_listing_version.agentGraphVersion, + "isDeleted": False, + }, + include=library_agent_include(user_id), + ) + return library_model.LibraryAgent.from_db(agent) if agent else None + + +async def get_library_agent_by_graph_id( + user_id: str, + graph_id: str, + graph_version: Optional[int] = None, +) -> library_model.LibraryAgent | None: + try: + filter: prisma.types.LibraryAgentWhereInput = { + "agentGraphId": graph_id, + "userId": user_id, + "isDeleted": False, + } + if graph_version is not None: + filter["agentGraphVersion"] = graph_version + + agent = await prisma.models.LibraryAgent.prisma().find_first( + where=filter, + include=library_agent_include(user_id), + ) + if not agent: + return None + + assert agent.AgentGraph # make type checker happy + # Include sub-graphs so we can make a full credentials input schema + sub_graphs = await graph_db.get_sub_graphs(agent.AgentGraph) + return library_model.LibraryAgent.from_db(agent, sub_graphs=sub_graphs) + except prisma.errors.PrismaError as e: + logger.error(f"Database error fetching library agent by graph ID: {e}") + raise store_exceptions.DatabaseError("Failed to fetch library agent") from e + + +async def add_generated_agent_image( + graph: graph_db.BaseGraph, + user_id: str, + library_agent_id: str, +) -> Optional[prisma.models.LibraryAgent]: + """ + Generates an image for the specified LibraryAgent and updates its record. + """ + graph_id = graph.id + + # Use .jpeg here since we are generating JPEG images + filename = f"agent_{graph_id}.jpeg" + try: + if not (image_url := await store_media.check_media_exists(user_id, filename)): + # Generate agent image as JPEG + image = await store_image_gen.generate_agent_image(graph) + + # Create UploadFile with the correct filename and content_type + image_file = fastapi.UploadFile(file=image, filename=filename) + + image_url = await store_media.upload_media( + user_id=user_id, file=image_file, use_file_name=True + ) + except Exception as e: + logger.warning(f"Error generating and uploading agent image: {e}") + return None + + return await prisma.models.LibraryAgent.prisma().update( + where={"id": library_agent_id}, + data={"imageUrl": image_url}, + ) + + +async def create_library_agent( + graph: graph_db.GraphModel, + user_id: str, + create_library_agents_for_sub_graphs: bool = True, +) -> list[library_model.LibraryAgent]: + """ + Adds an agent to the user's library (LibraryAgent table). + + Args: + agent: The agent/Graph to add to the library. + user_id: The user to whom the agent will be added. + create_library_agents_for_sub_graphs: If True, creates LibraryAgent records for sub-graphs as well. + + Returns: + The newly created LibraryAgent records. + If the graph has sub-graphs, the parent graph will always be the first entry in the list. + + Raises: + AgentNotFoundError: If the specified agent does not exist. + DatabaseError: If there's an error during creation or if image generation fails. + """ + logger.info( + f"Creating library agent for graph #{graph.id} v{graph.version}; " + f"user #{user_id}" + ) + graph_entries = ( + [graph, *graph.sub_graphs] if create_library_agents_for_sub_graphs else [graph] + ) + + async with transaction() as tx: + library_agents = await asyncio.gather( + *( + prisma.models.LibraryAgent.prisma(tx).create( + data=prisma.types.LibraryAgentCreateInput( + isCreatedByUser=(user_id == user_id), + useGraphIsActiveVersion=True, + User={"connect": {"id": user_id}}, + # Creator={"connect": {"id": user_id}}, + AgentGraph={ + "connect": { + "graphVersionId": { + "id": graph_entry.id, + "version": graph_entry.version, + } + } + }, + ), + include=library_agent_include(user_id), ) + for graph_entry in graph_entries ) + ) + + # Generate images for the main graph and sub-graphs + for agent, graph in zip(library_agents, graph_entries): + asyncio.create_task(add_generated_agent_image(graph, user_id, agent.id)) + + return [library_model.LibraryAgent.from_db(agent) for agent in library_agents] + + +async def update_agent_version_in_library( + user_id: str, + agent_graph_id: str, + agent_graph_version: int, +) -> None: + """ + Updates the agent version in the library if useGraphIsActiveVersion is True. - logger.debug(f"Found {len(result)} library agents") - return result + Args: + user_id: Owner of the LibraryAgent. + agent_graph_id: The agent graph's ID to update. + agent_graph_version: The new version of the agent graph. + Raises: + DatabaseError: If there's an error with the update. + """ + logger.debug( + f"Updating agent version in library for user #{user_id}, " + f"agent #{agent_graph_id} v{agent_graph_version}" + ) + try: + library_agent = await prisma.models.LibraryAgent.prisma().find_first_or_raise( + where={ + "userId": user_id, + "agentGraphId": agent_graph_id, + "useGraphIsActiveVersion": True, + }, + ) + await prisma.models.LibraryAgent.prisma().update( + where={"id": library_agent.id}, + data={ + "AgentGraph": { + "connect": { + "graphVersionId": { + "id": agent_graph_id, + "version": agent_graph_version, + } + }, + }, + }, + ) except prisma.errors.PrismaError as e: - logger.error(f"Database error getting library agents: {str(e)}") - raise backend.server.v2.store.exceptions.DatabaseError( - "Failed to fetch library agents" + logger.error(f"Database error updating agent version in library: {e}") + raise store_exceptions.DatabaseError( + "Failed to update agent version in library" ) from e -async def add_agent_to_library(store_listing_version_id: str, user_id: str) -> None: +async def update_library_agent( + library_agent_id: str, + user_id: str, + auto_update_version: Optional[bool] = None, + is_favorite: Optional[bool] = None, + is_archived: Optional[bool] = None, + is_deleted: Optional[Literal[False]] = None, +) -> library_model.LibraryAgent: """ - Finds the agent from the store listing version and adds it to the user's library (UserAgent table) - if they don't already have it + Updates the specified LibraryAgent record. + + Args: + library_agent_id: The ID of the LibraryAgent to update. + user_id: The owner of this LibraryAgent. + auto_update_version: Whether the agent should auto-update to active version. + is_favorite: Whether this agent is marked as a favorite. + is_archived: Whether this agent is archived. + + Returns: + The updated LibraryAgent. + + Raises: + NotFoundError: If the specified LibraryAgent does not exist. + DatabaseError: If there's an error in the update operation. """ logger.debug( - f"Adding agent from store listing version {store_listing_version_id} to library for user {user_id}" + f"Updating library agent {library_agent_id} for user {user_id} with " + f"auto_update_version={auto_update_version}, is_favorite={is_favorite}, " + f"is_archived={is_archived}" + ) + update_fields: prisma.types.LibraryAgentUpdateManyMutationInput = {} + if auto_update_version is not None: + update_fields["useGraphIsActiveVersion"] = auto_update_version + if is_favorite is not None: + update_fields["isFavorite"] = is_favorite + if is_archived is not None: + update_fields["isArchived"] = is_archived + if is_deleted is not None: + if is_deleted is True: + raise RuntimeError( + "Use delete_library_agent() to (soft-)delete library agents" + ) + update_fields["isDeleted"] = is_deleted + if not update_fields: + raise ValueError("No values were passed to update") + + try: + n_updated = await prisma.models.LibraryAgent.prisma().update_many( + where={"id": library_agent_id, "userId": user_id}, + data=update_fields, + ) + if n_updated < 1: + raise NotFoundError(f"Library agent {library_agent_id} not found") + + return await get_library_agent( + id=library_agent_id, + user_id=user_id, + ) + except prisma.errors.PrismaError as e: + logger.error(f"Database error updating library agent: {str(e)}") + raise store_exceptions.DatabaseError("Failed to update library agent") from e + + +async def delete_library_agent( + library_agent_id: str, user_id: str, soft_delete: bool = True +) -> None: + if soft_delete: + deleted_count = await prisma.models.LibraryAgent.prisma().update_many( + where={"id": library_agent_id, "userId": user_id}, data={"isDeleted": True} + ) + else: + deleted_count = await prisma.models.LibraryAgent.prisma().delete_many( + where={"id": library_agent_id, "userId": user_id} + ) + if deleted_count < 1: + raise NotFoundError(f"Library agent #{library_agent_id} not found") + + +async def delete_library_agent_by_graph_id(graph_id: str, user_id: str) -> None: + """ + Deletes a library agent for the given user + """ + try: + await prisma.models.LibraryAgent.prisma().delete_many( + where={"agentGraphId": graph_id, "userId": user_id} + ) + except prisma.errors.PrismaError as e: + logger.error(f"Database error deleting library agent: {e}") + raise store_exceptions.DatabaseError("Failed to delete library agent") from e + + +async def add_store_agent_to_library( + store_listing_version_id: str, user_id: str +) -> library_model.LibraryAgent: + """ + Adds an agent from a store listing version to the user's library if they don't already have it. + + Args: + store_listing_version_id: The ID of the store listing version containing the agent. + user_id: The user’s library to which the agent is being added. + + Returns: + The newly created LibraryAgent if successfully added, the existing corresponding one if any. + + Raises: + AgentNotFoundError: If the store listing or associated agent is not found. + DatabaseError: If there's an issue creating the LibraryAgent record. + """ + logger.debug( + f"Adding agent from store listing version #{store_listing_version_id} " + f"to library for user #{user_id}" ) try: - # Get store listing version to find agent store_listing_version = ( await prisma.models.StoreListingVersion.prisma().find_unique( - where={"id": store_listing_version_id}, include={"Agent": True} + where={"id": store_listing_version_id}, include={"AgentGraph": True} ) ) - - if not store_listing_version or not store_listing_version.Agent: + if not store_listing_version or not store_listing_version.AgentGraph: logger.warning( f"Store listing version not found: {store_listing_version_id}" ) - raise backend.server.v2.store.exceptions.AgentNotFoundError( - f"Store listing version {store_listing_version_id} not found" + raise store_exceptions.AgentNotFoundError( + f"Store listing version {store_listing_version_id} not found or invalid" ) - agent = store_listing_version.Agent - - if agent.userId == user_id: - logger.warning( - f"User {user_id} cannot add their own agent to their library" - ) - raise backend.server.v2.store.exceptions.DatabaseError( - "Cannot add own agent to library" - ) + graph = store_listing_version.AgentGraph # Check if user already has this agent - existing_user_agent = await prisma.models.UserAgent.prisma().find_first( + existing_library_agent = await prisma.models.LibraryAgent.prisma().find_unique( where={ - "userId": user_id, - "agentId": agent.id, - "agentVersion": agent.version, - } + "userId_agentGraphId_agentGraphVersion": { + "userId": user_id, + "agentGraphId": graph.id, + "agentGraphVersion": graph.version, + } + }, + include={"AgentGraph": True}, ) + if existing_library_agent: + if existing_library_agent.isDeleted: + # Even if agent exists it needs to be marked as not deleted + await update_library_agent( + existing_library_agent.id, user_id, is_deleted=False + ) + else: + logger.debug( + f"User #{user_id} already has graph #{graph.id} " + f"v{graph.version} in their library" + ) + return library_model.LibraryAgent.from_db(existing_library_agent) + + # Create LibraryAgent entry + added_agent = await prisma.models.LibraryAgent.prisma().create( + data={ + "User": {"connect": {"id": user_id}}, + "AgentGraph": { + "connect": { + "graphVersionId": {"id": graph.id, "version": graph.version} + } + }, + "isCreatedByUser": False, + }, + include=library_agent_include(user_id), + ) + logger.debug( + f"Added graph #{graph.id} v{graph.version}" + f"for store listing version #{store_listing_version.id} " + f"to library for user #{user_id}" + ) + return library_model.LibraryAgent.from_db(added_agent) + except store_exceptions.AgentNotFoundError: + # Reraise for external handling. + raise + except prisma.errors.PrismaError as e: + logger.error(f"Database error adding agent to library: {e}") + raise store_exceptions.DatabaseError("Failed to add agent to library") from e + + +############################################## +########### Presets DB Functions ############# +############################################## - if existing_user_agent: - logger.debug( - f"User {user_id} already has agent {agent.id} in their library" - ) - return - # Create UserAgent entry - await prisma.models.UserAgent.prisma().create( - data=prisma.types.UserAgentCreateInput( +async def list_presets( + user_id: str, page: int, page_size: int, graph_id: Optional[str] = None +) -> library_model.LibraryAgentPresetResponse: + """ + Retrieves a paginated list of AgentPresets for the specified user. + + Args: + user_id: The user ID whose presets are being retrieved. + page: The current page index (1-based). + page_size: Number of items to retrieve per page. + graph_id: Agent Graph ID to filter by. + + Returns: + A LibraryAgentPresetResponse containing a list of presets and pagination info. + + Raises: + DatabaseError: If there's a database error during the operation. + """ + logger.debug( + f"Fetching presets for user #{user_id}, page={page}, page_size={page_size}" + ) + + if page < 1 or page_size < 1: + logger.warning( + "Invalid pagination input: page=%d, page_size=%d", page, page_size + ) + raise store_exceptions.DatabaseError("Invalid pagination parameters") + + query_filter: prisma.types.AgentPresetWhereInput = { + "userId": user_id, + "isDeleted": False, + } + if graph_id: + query_filter["agentGraphId"] = graph_id + + try: + presets_records = await prisma.models.AgentPreset.prisma().find_many( + where=query_filter, + skip=(page - 1) * page_size, + take=page_size, + include={"InputPresets": True}, + ) + total_items = await prisma.models.AgentPreset.prisma().count(where=query_filter) + total_pages = (total_items + page_size - 1) // page_size + + presets = [ + library_model.LibraryAgentPreset.from_db(preset) + for preset in presets_records + ] + + return library_model.LibraryAgentPresetResponse( + presets=presets, + pagination=Pagination( + total_items=total_items, + total_pages=total_pages, + current_page=page, + page_size=page_size, + ), + ) + + except prisma.errors.PrismaError as e: + logger.error(f"Database error getting presets: {e}") + raise store_exceptions.DatabaseError("Failed to fetch presets") from e + + +async def get_preset( + user_id: str, preset_id: str +) -> library_model.LibraryAgentPreset | None: + """ + Retrieves a single AgentPreset by its ID for a given user. + + Args: + user_id: The user that owns the preset. + preset_id: The ID of the preset. + + Returns: + A LibraryAgentPreset if it exists and matches the user, otherwise None. + + Raises: + DatabaseError: If there's a database error during the fetch. + """ + logger.debug(f"Fetching preset #{preset_id} for user #{user_id}") + try: + preset = await prisma.models.AgentPreset.prisma().find_unique( + where={"id": preset_id}, + include={"InputPresets": True}, + ) + if not preset or preset.userId != user_id or preset.isDeleted: + return None + return library_model.LibraryAgentPreset.from_db(preset) + except prisma.errors.PrismaError as e: + logger.error(f"Database error getting preset: {e}") + raise store_exceptions.DatabaseError("Failed to fetch preset") from e + + +async def create_preset( + user_id: str, + preset: library_model.LibraryAgentPresetCreatable, +) -> library_model.LibraryAgentPreset: + """ + Creates a new AgentPreset for a user. + + Args: + user_id: The ID of the user creating the preset. + preset: The preset data used for creation. + + Returns: + The newly created LibraryAgentPreset. + + Raises: + DatabaseError: If there's a database error in creating the preset. + """ + logger.debug( + f"Creating preset ({repr(preset.name)}) for user #{user_id}", + ) + try: + new_preset = await prisma.models.AgentPreset.prisma().create( + data=prisma.types.AgentPresetCreateInput( userId=user_id, - agentId=agent.id, - agentVersion=agent.version, - isCreatedByUser=False, + name=preset.name, + description=preset.description, + agentGraphId=preset.graph_id, + agentGraphVersion=preset.graph_version, + isActive=preset.is_active, + webhookId=preset.webhook_id, + InputPresets={ + "create": [ + prisma.types.AgentNodeExecutionInputOutputCreateWithoutRelationsInput( # noqa + name=name, data=SafeJson(data) + ) + for name, data in { + **preset.inputs, + **{ + key: creds_meta.model_dump(exclude_none=True) + for key, creds_meta in preset.credentials.items() + }, + }.items() + ] + }, + ), + include={"InputPresets": True}, + ) + return library_model.LibraryAgentPreset.from_db(new_preset) + except prisma.errors.PrismaError as e: + logger.error(f"Database error creating preset: {e}") + raise store_exceptions.DatabaseError("Failed to create preset") from e + + +async def create_preset_from_graph_execution( + user_id: str, + create_request: library_model.LibraryAgentPresetCreatableFromGraphExecution, +) -> library_model.LibraryAgentPreset: + """ + Creates a new AgentPreset from an AgentGraphExecution. + + Params: + user_id: The ID of the user creating the preset. + create_request: The data used for creation. + + Returns: + The newly created LibraryAgentPreset. + + Raises: + DatabaseError: If there's a database error in creating the preset. + """ + graph_exec_id = create_request.graph_execution_id + graph_execution = await get_graph_execution(user_id, graph_exec_id) + if not graph_execution: + raise NotFoundError(f"Graph execution #{graph_exec_id} not found") + + logger.debug( + f"Creating preset for user #{user_id} from graph execution #{graph_exec_id}", + ) + return await create_preset( + user_id=user_id, + preset=library_model.LibraryAgentPresetCreatable( + inputs=graph_execution.inputs, + credentials={}, # FIXME + graph_id=graph_execution.graph_id, + graph_version=graph_execution.graph_version, + name=create_request.name, + description=create_request.description, + is_active=create_request.is_active, + ), + ) + + +async def update_preset( + user_id: str, + preset_id: str, + inputs: Optional[BlockInput] = None, + credentials: Optional[dict[str, CredentialsMetaInput]] = None, + name: Optional[str] = None, + description: Optional[str] = None, + is_active: Optional[bool] = None, +) -> library_model.LibraryAgentPreset: + """ + Updates an existing AgentPreset for a user. + + Args: + user_id: The ID of the user updating the preset. + preset_id: The ID of the preset to update. + inputs: New inputs object to set on the preset. + credentials: New credentials to set on the preset. + name: New name for the preset. + description: New description for the preset. + is_active: New active status for the preset. + + Returns: + The updated LibraryAgentPreset. + + Raises: + DatabaseError: If there's a database error in updating the preset. + NotFoundError: If attempting to update a non-existent preset. + """ + current = await get_preset(user_id, preset_id) # assert ownership + if not current: + raise NotFoundError(f"Preset #{preset_id} not found for user #{user_id}") + logger.debug( + f"Updating preset #{preset_id} ({repr(current.name)}) for user #{user_id}", + ) + try: + async with transaction() as tx: + update_data: prisma.types.AgentPresetUpdateInput = {} + if name: + update_data["name"] = name + if description: + update_data["description"] = description + if is_active is not None: + update_data["isActive"] = is_active + if inputs or credentials: + if not (inputs and credentials): + raise ValueError( + "Preset inputs and credentials must be provided together" + ) + update_data["InputPresets"] = { + "create": [ + prisma.types.AgentNodeExecutionInputOutputCreateWithoutRelationsInput( # noqa + name=name, data=SafeJson(data) + ) + for name, data in { + **inputs, + **{ + key: creds_meta.model_dump(exclude_none=True) + for key, creds_meta in credentials.items() + }, + }.items() + ], + } + # Existing InputPresets must be deleted, in a separate query + await prisma.models.AgentNodeExecutionInputOutput.prisma( + tx + ).delete_many(where={"agentPresetId": preset_id}) + + updated = await prisma.models.AgentPreset.prisma(tx).update( + where={"id": preset_id}, + data=update_data, + include={"InputPresets": True}, ) + if not updated: + raise RuntimeError(f"AgentPreset #{preset_id} vanished while updating") + return library_model.LibraryAgentPreset.from_db(updated) + except prisma.errors.PrismaError as e: + logger.error(f"Database error updating preset: {e}") + raise store_exceptions.DatabaseError("Failed to update preset") from e + + +async def set_preset_webhook( + user_id: str, preset_id: str, webhook_id: str | None +) -> library_model.LibraryAgentPreset: + current = await prisma.models.AgentPreset.prisma().find_unique( + where={"id": preset_id}, + include={"InputPresets": True}, + ) + if not current or current.userId != user_id: + raise NotFoundError(f"Preset #{preset_id} not found") + + updated = await prisma.models.AgentPreset.prisma().update( + where={"id": preset_id}, + data=( + {"Webhook": {"connect": {"id": webhook_id}}} + if webhook_id + else {"Webhook": {"disconnect": True}} + ), + include={"InputPresets": True}, + ) + if not updated: + raise RuntimeError(f"AgentPreset #{preset_id} vanished while updating") + return library_model.LibraryAgentPreset.from_db(updated) + + +async def delete_preset(user_id: str, preset_id: str) -> None: + """ + Soft-deletes a preset by marking it as isDeleted = True. + + Args: + user_id: The user that owns the preset. + preset_id: The ID of the preset to delete. + + Raises: + DatabaseError: If there's a database error during deletion. + """ + logger.debug(f"Setting preset #{preset_id} for user #{user_id} to deleted") + try: + await prisma.models.AgentPreset.prisma().update_many( + where={"id": preset_id, "userId": user_id}, + data={"isDeleted": True}, ) - logger.debug(f"Added agent {agent.id} to library for user {user_id}") + except prisma.errors.PrismaError as e: + logger.error(f"Database error deleting preset: {e}") + raise store_exceptions.DatabaseError("Failed to delete preset") from e - except backend.server.v2.store.exceptions.AgentNotFoundError: - raise + +async def fork_library_agent( + library_agent_id: str, user_id: str +) -> library_model.LibraryAgent: + """ + Clones a library agent and its underyling graph and nodes (with new ids) for the given user. + + Args: + library_agent_id: The ID of the library agent to fork. + user_id: The ID of the user who owns the library agent. + + Returns: + The forked parent (if it has sub-graphs) LibraryAgent. + + Raises: + DatabaseError: If there's an error during the forking process. + """ + logger.debug(f"Forking library agent {library_agent_id} for user {user_id}") + try: + # Fetch the original agent + original_agent = await get_library_agent(library_agent_id, user_id) + + # Check if user owns the library agent + # TODO: once we have open/closed sourced agents this needs to be enabled ~kcze + # + update library/agents/[id]/page.tsx agent actions + # if not original_agent.can_access_graph: + # raise store_exceptions.DatabaseError( + # f"User {user_id} cannot access library agent graph {library_agent_id}" + # ) + + # Fork the underlying graph and nodes + new_graph = await graph_db.fork_graph( + original_agent.graph_id, original_agent.graph_version, user_id + ) + new_graph = await on_graph_activate(new_graph, user_id=user_id) + + # Create a library agent for the new graph + return (await create_library_agent(new_graph, user_id))[0] except prisma.errors.PrismaError as e: - logger.error(f"Database error adding agent to library: {str(e)}") - raise backend.server.v2.store.exceptions.DatabaseError( - "Failed to add agent to library" - ) from e + logger.error(f"Database error cloning library agent: {e}") + raise store_exceptions.DatabaseError("Failed to fork library agent") from e diff --git a/autogpt_platform/backend/backend/server/v2/library/db_test.py b/autogpt_platform/backend/backend/server/v2/library/db_test.py index e06d4bfa9af3..7cb34ed75a82 100644 --- a/autogpt_platform/backend/backend/server/v2/library/db_test.py +++ b/autogpt_platform/backend/backend/server/v2/library/db_test.py @@ -1,23 +1,15 @@ from datetime import datetime +import prisma.enums import prisma.errors import prisma.models +import prisma.types import pytest -from prisma import Prisma -import backend.data.includes import backend.server.v2.library.db as db import backend.server.v2.store.exceptions - - -@pytest.fixture(autouse=True) -async def setup_prisma(): - # Don't register client if already registered - try: - Prisma() - except prisma.errors.ClientAlreadyRegisteredError: - pass - yield +from backend.data.db import connect +from backend.data.includes import library_agent_include @pytest.mark.asyncio @@ -32,23 +24,23 @@ async def test_get_library_agents(mocker): userId="test-user", isActive=True, createdAt=datetime.now(), - isTemplate=False, ) ] mock_library_agents = [ - prisma.models.UserAgent( + prisma.models.LibraryAgent( id="ua1", userId="test-user", - agentId="agent2", - agentVersion=1, + agentGraphId="agent2", + agentGraphVersion=1, isCreatedByUser=False, isDeleted=False, isArchived=False, createdAt=datetime.now(), updatedAt=datetime.now(), isFavorite=False, - Agent=prisma.models.AgentGraph( + useGraphIsActiveVersion=True, + AgentGraph=prisma.models.AgentGraph( id="agent2", version=1, name="Test Agent 2", @@ -56,7 +48,6 @@ async def test_get_library_agents(mocker): userId="other-user", isActive=True, createdAt=datetime.now(), - isTemplate=False, ), ) ] @@ -67,62 +58,46 @@ async def test_get_library_agents(mocker): return_value=mock_user_created ) - mock_user_agent = mocker.patch("prisma.models.UserAgent.prisma") - mock_user_agent.return_value.find_many = mocker.AsyncMock( + mock_library_agent = mocker.patch("prisma.models.LibraryAgent.prisma") + mock_library_agent.return_value.find_many = mocker.AsyncMock( return_value=mock_library_agents ) + mock_library_agent.return_value.count = mocker.AsyncMock(return_value=1) # Call function - result = await db.get_library_agents("test-user") + result = await db.list_library_agents("test-user") # Verify results - assert len(result) == 2 - assert result[0].id == "agent1" - assert result[0].name == "Test Agent 1" - assert result[0].description == "Test Description 1" - assert result[0].isCreatedByUser is True - assert result[1].id == "agent2" - assert result[1].name == "Test Agent 2" - assert result[1].description == "Test Description 2" - assert result[1].isCreatedByUser is False - - # Verify mocks called correctly - mock_agent_graph.return_value.find_many.assert_called_once_with( - where=prisma.types.AgentGraphWhereInput(userId="test-user", isActive=True), - include=backend.data.includes.AGENT_GRAPH_INCLUDE, - ) - mock_user_agent.return_value.find_many.assert_called_once_with( - where=prisma.types.UserAgentWhereInput( - userId="test-user", isDeleted=False, isArchived=False - ), - include={ - "Agent": { - "include": { - "AgentNodes": { - "include": { - "Input": True, - "Output": True, - "Webhook": True, - "AgentBlock": True, - } - } - } - } - }, - ) - - -@pytest.mark.asyncio + assert len(result.agents) == 1 + assert result.agents[0].id == "ua1" + assert result.agents[0].name == "Test Agent 2" + assert result.agents[0].description == "Test Description 2" + assert result.agents[0].graph_id == "agent2" + assert result.agents[0].graph_version == 1 + assert result.agents[0].can_access_graph is False + assert result.agents[0].is_latest_version is True + assert result.pagination.total_items == 1 + assert result.pagination.total_pages == 1 + assert result.pagination.current_page == 1 + assert result.pagination.page_size == 50 + + +@pytest.mark.asyncio(loop_scope="session") async def test_add_agent_to_library(mocker): + await connect() + + # Mock the transaction context + mock_transaction = mocker.patch("backend.server.v2.library.db.transaction") + mock_transaction.return_value.__aenter__ = mocker.AsyncMock(return_value=None) + mock_transaction.return_value.__aexit__ = mocker.AsyncMock(return_value=None) # Mock data - mock_store_listing = prisma.models.StoreListingVersion( + mock_store_listing_data = prisma.models.StoreListingVersion( id="version123", version=1, createdAt=datetime.now(), updatedAt=datetime.now(), - agentId="agent1", - agentVersion=1, - slug="test-agent", + agentGraphId="agent1", + agentGraphVersion=1, name="Test Agent", subHeading="Test Agent Subheading", imageUrls=["https://example.com/image.jpg"], @@ -131,8 +106,9 @@ async def test_add_agent_to_library(mocker): isFeatured=False, isDeleted=False, isAvailable=True, - isApproved=True, - Agent=prisma.models.AgentGraph( + storeListingId="listing123", + submissionStatus=prisma.enums.SubmissionStatus.APPROVED, + AgentGraph=prisma.models.AgentGraph( id="agent1", version=1, name="Test Agent", @@ -140,45 +116,74 @@ async def test_add_agent_to_library(mocker): userId="creator", isActive=True, createdAt=datetime.now(), - isTemplate=False, ), ) + mock_library_agent_data = prisma.models.LibraryAgent( + id="ua1", + userId="test-user", + agentGraphId=mock_store_listing_data.agentGraphId, + agentGraphVersion=1, + isCreatedByUser=False, + isDeleted=False, + isArchived=False, + createdAt=datetime.now(), + updatedAt=datetime.now(), + isFavorite=False, + useGraphIsActiveVersion=True, + AgentGraph=mock_store_listing_data.AgentGraph, + ) + # Mock prisma calls mock_store_listing_version = mocker.patch( "prisma.models.StoreListingVersion.prisma" ) mock_store_listing_version.return_value.find_unique = mocker.AsyncMock( - return_value=mock_store_listing + return_value=mock_store_listing_data + ) + + mock_library_agent = mocker.patch("prisma.models.LibraryAgent.prisma") + mock_library_agent.return_value.find_unique = mocker.AsyncMock(return_value=None) + mock_library_agent.return_value.create = mocker.AsyncMock( + return_value=mock_library_agent_data ) - mock_user_agent = mocker.patch("prisma.models.UserAgent.prisma") - mock_user_agent.return_value.find_first = mocker.AsyncMock(return_value=None) - mock_user_agent.return_value.create = mocker.AsyncMock() + # Mock the model conversion + mock_from_db = mocker.patch("backend.server.v2.library.model.LibraryAgent.from_db") + mock_from_db.return_value = mocker.Mock() # Call function - await db.add_agent_to_library("version123", "test-user") + await db.add_store_agent_to_library("version123", "test-user") # Verify mocks called correctly mock_store_listing_version.return_value.find_unique.assert_called_once_with( - where={"id": "version123"}, include={"Agent": True} + where={"id": "version123"}, include={"AgentGraph": True} ) - mock_user_agent.return_value.find_first.assert_called_once_with( + mock_library_agent.return_value.find_unique.assert_called_once_with( where={ - "userId": "test-user", - "agentId": "agent1", - "agentVersion": 1, - } + "userId_agentGraphId_agentGraphVersion": { + "userId": "test-user", + "agentGraphId": "agent1", + "agentGraphVersion": 1, + } + }, + include={"AgentGraph": True}, ) - mock_user_agent.return_value.create.assert_called_once_with( - data=prisma.types.UserAgentCreateInput( - userId="test-user", agentId="agent1", agentVersion=1, isCreatedByUser=False - ) + mock_library_agent.return_value.create.assert_called_once_with( + data={ + "User": {"connect": {"id": "test-user"}}, + "AgentGraph": { + "connect": {"graphVersionId": {"id": "agent1", "version": 1}} + }, + "isCreatedByUser": False, + }, + include=library_agent_include("test-user"), ) -@pytest.mark.asyncio +@pytest.mark.asyncio(loop_scope="session") async def test_add_agent_to_library_not_found(mocker): + await connect() # Mock prisma calls mock_store_listing_version = mocker.patch( "prisma.models.StoreListingVersion.prisma" @@ -189,9 +194,9 @@ async def test_add_agent_to_library_not_found(mocker): # Call function and verify exception with pytest.raises(backend.server.v2.store.exceptions.AgentNotFoundError): - await db.add_agent_to_library("version123", "test-user") + await db.add_store_agent_to_library("version123", "test-user") # Verify mock called correctly mock_store_listing_version.return_value.find_unique.assert_called_once_with( - where={"id": "version123"}, include={"Agent": True} + where={"id": "version123"}, include={"AgentGraph": True} ) diff --git a/autogpt_platform/backend/backend/server/v2/library/model.py b/autogpt_platform/backend/backend/server/v2/library/model.py index 88a81f6d77a0..3838d9a88db2 100644 --- a/autogpt_platform/backend/backend/server/v2/library/model.py +++ b/autogpt_platform/backend/backend/server/v2/library/model.py @@ -1,16 +1,355 @@ -import typing +import datetime +from enum import Enum +from typing import Any, Optional +import prisma.enums +import prisma.models import pydantic +import backend.data.block as block_model +import backend.data.graph as graph_model +from backend.data.model import CredentialsMetaInput, is_credentials_field_name +from backend.integrations.providers import ProviderName +from backend.util.models import Pagination + + +class LibraryAgentStatus(str, Enum): + COMPLETED = "COMPLETED" # All runs completed + HEALTHY = "HEALTHY" # Agent is running (not all runs have completed) + WAITING = "WAITING" # Agent is queued or waiting to start + ERROR = "ERROR" # Agent is in an error state + + +class LibraryAgentTriggerInfo(pydantic.BaseModel): + provider: ProviderName + config_schema: dict[str, Any] = pydantic.Field( + description="Input schema for the trigger block" + ) + credentials_input_name: Optional[str] + class LibraryAgent(pydantic.BaseModel): - id: str # Changed from agent_id to match GraphMeta - version: int # Changed from agent_version to match GraphMeta - is_active: bool # Added to match GraphMeta + """ + Represents an agent in the library, including metadata for display and + user interaction within the system. + """ + + id: str + graph_id: str + graph_version: int + + image_url: str | None + + creator_name: str + creator_image_url: str + + status: LibraryAgentStatus + + updated_at: datetime.datetime + + name: str + description: str + + input_schema: dict[str, Any] # Should be BlockIOObjectSubSchema in frontend + output_schema: dict[str, Any] + credentials_input_schema: dict[str, Any] | None = pydantic.Field( + description="Input schema for credentials required by the agent", + ) + + has_external_trigger: bool = pydantic.Field( + description="Whether the agent has an external trigger (e.g. webhook) node" + ) + trigger_setup_info: Optional[LibraryAgentTriggerInfo] = None + + # Indicates whether there's a new output (based on recent runs) + new_output: bool + + # Whether the user can access the underlying graph + can_access_graph: bool + + # Indicates if this agent is the latest version + is_latest_version: bool + + @staticmethod + def from_db( + agent: prisma.models.LibraryAgent, + sub_graphs: Optional[list[prisma.models.AgentGraph]] = None, + ) -> "LibraryAgent": + """ + Factory method that constructs a LibraryAgent from a Prisma LibraryAgent + model instance. + """ + if not agent.AgentGraph: + raise ValueError("Associated Agent record is required.") + + graph = graph_model.GraphModel.from_db(agent.AgentGraph, sub_graphs=sub_graphs) + + agent_updated_at = agent.AgentGraph.updatedAt + lib_agent_updated_at = agent.updatedAt + + # Compute updated_at as the latest between library agent and graph + updated_at = ( + max(agent_updated_at, lib_agent_updated_at) + if agent_updated_at + else lib_agent_updated_at + ) + + creator_name = "Unknown" + creator_image_url = "" + if agent.Creator: + creator_name = agent.Creator.name or "Unknown" + creator_image_url = agent.Creator.avatarUrl or "" + + # Logic to calculate status and new_output + week_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta( + days=7 + ) + executions = agent.AgentGraph.Executions or [] + status_result = _calculate_agent_status(executions, week_ago) + status = status_result.status + new_output = status_result.new_output + + # Check if user can access the graph + can_access_graph = agent.AgentGraph.userId == agent.userId + + # Hard-coded to True until a method to check is implemented + is_latest_version = True + + return LibraryAgent( + id=agent.id, + graph_id=agent.agentGraphId, + graph_version=agent.agentGraphVersion, + image_url=agent.imageUrl, + creator_name=creator_name, + creator_image_url=creator_image_url, + status=status, + updated_at=updated_at, + name=graph.name, + description=graph.description, + input_schema=graph.input_schema, + output_schema=graph.output_schema, + credentials_input_schema=( + graph.credentials_input_schema if sub_graphs is not None else None + ), + has_external_trigger=graph.has_external_trigger, + trigger_setup_info=( + LibraryAgentTriggerInfo( + provider=trigger_block.webhook_config.provider, + config_schema={ + **(json_schema := trigger_block.input_schema.jsonschema()), + "properties": { + pn: sub_schema + for pn, sub_schema in json_schema["properties"].items() + if not is_credentials_field_name(pn) + }, + "required": [ + pn + for pn in json_schema.get("required", []) + if not is_credentials_field_name(pn) + ], + }, + credentials_input_name=next( + iter(trigger_block.input_schema.get_credentials_fields()), None + ), + ) + if graph.webhook_input_node + and (trigger_block := graph.webhook_input_node.block).webhook_config + else None + ), + new_output=new_output, + can_access_graph=can_access_graph, + is_latest_version=is_latest_version, + ) + + +class AgentStatusResult(pydantic.BaseModel): + status: LibraryAgentStatus + new_output: bool + + +def _calculate_agent_status( + executions: list[prisma.models.AgentGraphExecution], + recent_threshold: datetime.datetime, +) -> AgentStatusResult: + """ + Helper function to determine the overall agent status and whether there + is new output (i.e., completed runs within the recent threshold). + + :param executions: A list of AgentGraphExecution objects. + :param recent_threshold: A datetime; any execution after this indicates new output. + :return: (AgentStatus, new_output_flag) + """ + + if not executions: + return AgentStatusResult(status=LibraryAgentStatus.COMPLETED, new_output=False) + + # Track how many times each execution status appears + status_counts = {status: 0 for status in prisma.enums.AgentExecutionStatus} + new_output = False + + for execution in executions: + # Check if there's a completed run more recent than `recent_threshold` + if execution.createdAt >= recent_threshold: + if execution.executionStatus == prisma.enums.AgentExecutionStatus.COMPLETED: + new_output = True + status_counts[execution.executionStatus] += 1 + + # Determine the final status based on counts + if status_counts[prisma.enums.AgentExecutionStatus.FAILED] > 0: + return AgentStatusResult(status=LibraryAgentStatus.ERROR, new_output=new_output) + elif status_counts[prisma.enums.AgentExecutionStatus.QUEUED] > 0: + return AgentStatusResult( + status=LibraryAgentStatus.WAITING, new_output=new_output + ) + elif status_counts[prisma.enums.AgentExecutionStatus.RUNNING] > 0: + return AgentStatusResult( + status=LibraryAgentStatus.HEALTHY, new_output=new_output + ) + else: + return AgentStatusResult( + status=LibraryAgentStatus.COMPLETED, new_output=new_output + ) + + +class LibraryAgentResponse(pydantic.BaseModel): + """Response schema for a list of library agents and pagination info.""" + + agents: list[LibraryAgent] + pagination: Pagination + + +class LibraryAgentPresetCreatable(pydantic.BaseModel): + """ + Request model used when creating a new preset for a library agent. + """ + + graph_id: str + graph_version: int + + inputs: block_model.BlockInput + credentials: dict[str, CredentialsMetaInput] + + name: str + description: str + + is_active: bool = True + + webhook_id: Optional[str] = None + + +class LibraryAgentPresetCreatableFromGraphExecution(pydantic.BaseModel): + """ + Request model used when creating a new preset for a library agent. + """ + + graph_execution_id: str + name: str description: str - isCreatedByUser: bool - # Made input_schema and output_schema match GraphMeta's type - input_schema: dict[str, typing.Any] # Should be BlockIOObjectSubSchema in frontend - output_schema: dict[str, typing.Any] # Should be BlockIOObjectSubSchema in frontend + is_active: bool = True + + +class LibraryAgentPresetUpdatable(pydantic.BaseModel): + """ + Request model used when updating a preset for a library agent. + """ + + inputs: Optional[block_model.BlockInput] = None + credentials: Optional[dict[str, CredentialsMetaInput]] = None + + name: Optional[str] = None + description: Optional[str] = None + + is_active: Optional[bool] = None + + +class TriggeredPresetSetupRequest(pydantic.BaseModel): + name: str + description: str = "" + + graph_id: str + graph_version: int + + trigger_config: dict[str, Any] + agent_credentials: dict[str, CredentialsMetaInput] = pydantic.Field( + default_factory=dict + ) + + +class LibraryAgentPreset(LibraryAgentPresetCreatable): + """Represents a preset configuration for a library agent.""" + + id: str + user_id: str + updated_at: datetime.datetime + + @classmethod + def from_db(cls, preset: prisma.models.AgentPreset) -> "LibraryAgentPreset": + if preset.InputPresets is None: + raise ValueError("InputPresets must be included in AgentPreset query") + + input_data: block_model.BlockInput = {} + input_credentials: dict[str, CredentialsMetaInput] = {} + + for preset_input in preset.InputPresets: + if not is_credentials_field_name(preset_input.name): + input_data[preset_input.name] = preset_input.data + else: + input_credentials[preset_input.name] = ( + CredentialsMetaInput.model_validate(preset_input.data) + ) + + return cls( + id=preset.id, + user_id=preset.userId, + updated_at=preset.updatedAt, + graph_id=preset.agentGraphId, + graph_version=preset.agentGraphVersion, + name=preset.name, + description=preset.description, + is_active=preset.isActive, + inputs=input_data, + credentials=input_credentials, + webhook_id=preset.webhookId, + ) + + +class LibraryAgentPresetResponse(pydantic.BaseModel): + """Response schema for a list of agent presets and pagination info.""" + + presets: list[LibraryAgentPreset] + pagination: Pagination + + +class LibraryAgentFilter(str, Enum): + """Possible filters for searching library agents.""" + + IS_FAVOURITE = "isFavourite" + IS_CREATED_BY_USER = "isCreatedByUser" + + +class LibraryAgentSort(str, Enum): + """Possible sort options for sorting library agents.""" + + CREATED_AT = "createdAt" + UPDATED_AT = "updatedAt" + + +class LibraryAgentUpdateRequest(pydantic.BaseModel): + """ + Schema for updating a library agent via PUT. + + Includes flags for auto-updating version, marking as favorite, + archiving, or deleting. + """ + + auto_update_version: Optional[bool] = pydantic.Field( + default=None, description="Auto-update the agent version" + ) + is_favorite: Optional[bool] = pydantic.Field( + default=None, description="Mark the agent as a favorite" + ) + is_archived: Optional[bool] = pydantic.Field( + default=None, description="Archive the agent" + ) diff --git a/autogpt_platform/backend/backend/server/v2/library/model_test.py b/autogpt_platform/backend/backend/server/v2/library/model_test.py index 81aa8fe07b23..898cc2502c01 100644 --- a/autogpt_platform/backend/backend/server/v2/library/model_test.py +++ b/autogpt_platform/backend/backend/server/v2/library/model_test.py @@ -1,43 +1,44 @@ -import backend.server.v2.library.model +import datetime +import prisma.fields +import prisma.models +import pytest -def test_library_agent(): - agent = backend.server.v2.library.model.LibraryAgent( +import backend.server.v2.library.model as library_model + + +@pytest.mark.asyncio +async def test_agent_preset_from_db(): + # Create mock DB agent + db_agent = prisma.models.AgentPreset( id="test-agent-123", - version=1, - is_active=True, + createdAt=datetime.datetime.now(), + updatedAt=datetime.datetime.now(), + agentGraphId="agent-123", + agentGraphVersion=1, name="Test Agent", - description="Test description", - isCreatedByUser=False, - input_schema={"type": "object", "properties": {}}, - output_schema={"type": "object", "properties": {}}, + description="Test agent description", + isActive=True, + userId="test-user-123", + isDeleted=False, + InputPresets=[ + prisma.models.AgentNodeExecutionInputOutput.model_validate( + { + "id": "input-123", + "time": datetime.datetime.now(), + "name": "input1", + "data": '{"type": "string", "value": "test value"}', + } + ) + ], ) - assert agent.id == "test-agent-123" - assert agent.version == 1 - assert agent.is_active is True - assert agent.name == "Test Agent" - assert agent.description == "Test description" - assert agent.isCreatedByUser is False - assert agent.input_schema == {"type": "object", "properties": {}} - assert agent.output_schema == {"type": "object", "properties": {}} + # Convert to LibraryAgentPreset + agent = library_model.LibraryAgentPreset.from_db(db_agent) -def test_library_agent_with_user_created(): - agent = backend.server.v2.library.model.LibraryAgent( - id="user-agent-456", - version=2, - is_active=True, - name="User Created Agent", - description="An agent created by the user", - isCreatedByUser=True, - input_schema={"type": "object", "properties": {}}, - output_schema={"type": "object", "properties": {}}, - ) - assert agent.id == "user-agent-456" - assert agent.version == 2 + assert agent.id == "test-agent-123" + assert agent.graph_version == 1 assert agent.is_active is True - assert agent.name == "User Created Agent" - assert agent.description == "An agent created by the user" - assert agent.isCreatedByUser is True - assert agent.input_schema == {"type": "object", "properties": {}} - assert agent.output_schema == {"type": "object", "properties": {}} + assert agent.name == "Test Agent" + assert agent.description == "Test agent description" + assert agent.inputs == {"input1": {"type": "string", "value": "test value"}} diff --git a/autogpt_platform/backend/backend/server/v2/library/routes.py b/autogpt_platform/backend/backend/server/v2/library/routes.py deleted file mode 100644 index 7f3e89fe903a..000000000000 --- a/autogpt_platform/backend/backend/server/v2/library/routes.py +++ /dev/null @@ -1,74 +0,0 @@ -import logging -import typing - -import autogpt_libs.auth.depends -import autogpt_libs.auth.middleware -import fastapi - -import backend.data.graph -import backend.server.v2.library.db -import backend.server.v2.library.model - -logger = logging.getLogger(__name__) - -router = fastapi.APIRouter() - - -@router.get( - "/agents", - tags=["library", "private"], - dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], -) -async def get_library_agents( - user_id: typing.Annotated[ - str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) - ] -) -> typing.Sequence[backend.server.v2.library.model.LibraryAgent]: - """ - Get all agents in the user's library, including both created and saved agents. - """ - try: - agents = await backend.server.v2.library.db.get_library_agents(user_id) - return agents - except Exception: - logger.exception("Exception occurred whilst getting library agents") - raise fastapi.HTTPException( - status_code=500, detail="Failed to get library agents" - ) - - -@router.post( - "/agents/{store_listing_version_id}", - tags=["library", "private"], - dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], - status_code=201, -) -async def add_agent_to_library( - store_listing_version_id: str, - user_id: typing.Annotated[ - str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) - ], -) -> fastapi.Response: - """ - Add an agent from the store to the user's library. - - Args: - store_listing_version_id (str): ID of the store listing version to add - user_id (str): ID of the authenticated user - - Returns: - fastapi.Response: 201 status code on success - - Raises: - HTTPException: If there is an error adding the agent to the library - """ - try: - await backend.server.v2.library.db.add_agent_to_library( - store_listing_version_id=store_listing_version_id, user_id=user_id - ) - return fastapi.Response(status_code=201) - except Exception: - logger.exception("Exception occurred whilst adding agent to library") - raise fastapi.HTTPException( - status_code=500, detail="Failed to add agent to library" - ) diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/__init__.py b/autogpt_platform/backend/backend/server/v2/library/routes/__init__.py new file mode 100644 index 000000000000..f62cbe7ff23c --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/library/routes/__init__.py @@ -0,0 +1,9 @@ +import fastapi + +from .agents import router as agents_router +from .presets import router as presets_router + +router = fastapi.APIRouter() + +router.include_router(presets_router) +router.include_router(agents_router) diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/agents.py b/autogpt_platform/backend/backend/server/v2/library/routes/agents.py new file mode 100644 index 000000000000..60f94f519bb7 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/library/routes/agents.py @@ -0,0 +1,291 @@ +import logging +from typing import Optional + +import autogpt_libs.auth as autogpt_auth_lib +from fastapi import APIRouter, Body, Depends, HTTPException, Query, status +from fastapi.responses import Response + +import backend.server.v2.library.db as library_db +import backend.server.v2.library.model as library_model +import backend.server.v2.store.exceptions as store_exceptions +from backend.util.exceptions import NotFoundError + +logger = logging.getLogger(__name__) + +router = APIRouter( + prefix="/agents", + tags=["library", "private"], + dependencies=[Depends(autogpt_auth_lib.auth_middleware)], +) + + +@router.get( + "", + summary="List Library Agents", + responses={ + 500: {"description": "Server error", "content": {"application/json": {}}}, + }, +) +async def list_library_agents( + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), + search_term: Optional[str] = Query( + None, description="Search term to filter agents" + ), + sort_by: library_model.LibraryAgentSort = Query( + library_model.LibraryAgentSort.UPDATED_AT, + description="Criteria to sort results by", + ), + page: int = Query( + 1, + ge=1, + description="Page number to retrieve (must be >= 1)", + ), + page_size: int = Query( + 15, + ge=1, + description="Number of agents per page (must be >= 1)", + ), +) -> library_model.LibraryAgentResponse: + """ + Get all agents in the user's library (both created and saved). + + Args: + user_id: ID of the authenticated user. + search_term: Optional search term to filter agents by name/description. + filter_by: List of filters to apply (favorites, created by user). + sort_by: List of sorting criteria (created date, updated date). + page: Page number to retrieve. + page_size: Number of agents per page. + + Returns: + A LibraryAgentResponse containing agents and pagination metadata. + + Raises: + HTTPException: If a server/database error occurs. + """ + try: + return await library_db.list_library_agents( + user_id=user_id, + search_term=search_term, + sort_by=sort_by, + page=page, + page_size=page_size, + ) + except Exception as e: + logger.error(f"Could not list library agents for user #{user_id}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + + +@router.get("/{library_agent_id}", summary="Get Library Agent") +async def get_library_agent( + library_agent_id: str, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> library_model.LibraryAgent: + return await library_db.get_library_agent(id=library_agent_id, user_id=user_id) + + +@router.get("/by-graph/{graph_id}") +async def get_library_agent_by_graph_id( + graph_id: str, + version: Optional[int] = Query(default=None), + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> library_model.LibraryAgent: + library_agent = await library_db.get_library_agent_by_graph_id( + user_id, graph_id, version + ) + if not library_agent: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Library agent for graph #{graph_id} and user #{user_id} not found", + ) + return library_agent + + +@router.get( + "/marketplace/{store_listing_version_id}", + summary="Get Agent By Store ID", + tags=["store, library"], +) +async def get_library_agent_by_store_listing_version_id( + store_listing_version_id: str, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> library_model.LibraryAgent | None: + """ + Get Library Agent from Store Listing Version ID. + """ + try: + return await library_db.get_library_agent_by_store_version_id( + store_listing_version_id, user_id + ) + except NotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) + except Exception as e: + logger.error(f"Could not fetch library agent from store version ID: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) from e + + +@router.post( + "", + summary="Add Marketplace Agent", + status_code=status.HTTP_201_CREATED, + responses={ + 201: {"description": "Agent added successfully"}, + 404: {"description": "Store listing version not found"}, + 500: {"description": "Server error"}, + }, +) +async def add_marketplace_agent_to_library( + store_listing_version_id: str = Body(embed=True), + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> library_model.LibraryAgent: + """ + Add an agent from the marketplace to the user's library. + + Args: + store_listing_version_id: ID of the store listing version to add. + user_id: ID of the authenticated user. + + Returns: + library_model.LibraryAgent: Agent added to the library + + Raises: + HTTPException(404): If the listing version is not found. + HTTPException(500): If a server/database error occurs. + """ + try: + return await library_db.add_store_agent_to_library( + store_listing_version_id=store_listing_version_id, + user_id=user_id, + ) + + except store_exceptions.AgentNotFoundError as e: + logger.warning( + f"Could not find store listing version {store_listing_version_id} " + "to add to library" + ) + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except store_exceptions.DatabaseError as e: + logger.error(f"Database error while adding agent to library: {e}", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"message": str(e), "hint": "Inspect DB logs for details."}, + ) from e + except Exception as e: + logger.error(f"Unexpected error while adding agent to library: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={ + "message": str(e), + "hint": "Check server logs for more information.", + }, + ) from e + + +@router.patch( + "/{library_agent_id}", + summary="Update Library Agent", + responses={ + 200: {"description": "Agent updated successfully"}, + 500: {"description": "Server error"}, + }, +) +async def update_library_agent( + library_agent_id: str, + payload: library_model.LibraryAgentUpdateRequest, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> library_model.LibraryAgent: + """ + Update the library agent with the given fields. + + Args: + library_agent_id: ID of the library agent to update. + payload: Fields to update (auto_update_version, is_favorite, etc.). + user_id: ID of the authenticated user. + + Raises: + HTTPException(500): If a server/database error occurs. + """ + try: + return await library_db.update_library_agent( + library_agent_id=library_agent_id, + user_id=user_id, + auto_update_version=payload.auto_update_version, + is_favorite=payload.is_favorite, + is_archived=payload.is_archived, + ) + except NotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + except store_exceptions.DatabaseError as e: + logger.error(f"Database error while updating library agent: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"message": str(e), "hint": "Verify DB connection."}, + ) from e + except Exception as e: + logger.error(f"Unexpected error while updating library agent: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"message": str(e), "hint": "Check server logs."}, + ) from e + + +@router.delete( + "/{library_agent_id}", + summary="Delete Library Agent", + responses={ + 204: {"description": "Agent deleted successfully"}, + 404: {"description": "Agent not found"}, + 500: {"description": "Server error"}, + }, +) +async def delete_library_agent( + library_agent_id: str, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> Response: + """ + Soft-delete the specified library agent. + + Args: + library_agent_id: ID of the library agent to delete. + user_id: ID of the authenticated user. + + Returns: + 204 No Content if successful. + + Raises: + HTTPException(404): If the agent does not exist. + HTTPException(500): If a server/database error occurs. + """ + try: + await library_db.delete_library_agent( + library_agent_id=library_agent_id, user_id=user_id + ) + return Response(status_code=status.HTTP_204_NO_CONTENT) + except NotFoundError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e), + ) from e + + +@router.post("/{library_agent_id}/fork", summary="Fork Library Agent") +async def fork_library_agent( + library_agent_id: str, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> library_model.LibraryAgent: + return await library_db.fork_library_agent( + library_agent_id=library_agent_id, + user_id=user_id, + ) diff --git a/autogpt_platform/backend/backend/server/v2/library/routes/presets.py b/autogpt_platform/backend/backend/server/v2/library/routes/presets.py new file mode 100644 index 000000000000..4a64ec425ee0 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/library/routes/presets.py @@ -0,0 +1,413 @@ +import logging +from typing import Any, Optional + +import autogpt_libs.auth as autogpt_auth_lib +from fastapi import APIRouter, Body, Depends, HTTPException, Query, status + +import backend.server.v2.library.db as db +import backend.server.v2.library.model as models +from backend.data.graph import get_graph +from backend.data.integrations import get_webhook +from backend.executor.utils import add_graph_execution, make_node_credentials_input_map +from backend.integrations.creds_manager import IntegrationCredentialsManager +from backend.integrations.webhooks import get_webhook_manager +from backend.integrations.webhooks.utils import setup_webhook_for_block +from backend.util.exceptions import NotFoundError + +logger = logging.getLogger(__name__) + +credentials_manager = IntegrationCredentialsManager() +router = APIRouter(tags=["presets"]) + + +@router.get( + "/presets", + summary="List presets", + description="Retrieve a paginated list of presets for the current user.", +) +async def list_presets( + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), + page: int = Query(default=1, ge=1), + page_size: int = Query(default=10, ge=1), + graph_id: Optional[str] = Query( + description="Allows to filter presets by a specific agent graph" + ), +) -> models.LibraryAgentPresetResponse: + """ + Retrieve a paginated list of presets for the current user. + + Args: + user_id (str): ID of the authenticated user. + page (int): Page number for pagination. + page_size (int): Number of items per page. + graph_id: Allows to filter presets by a specific agent graph. + + Returns: + models.LibraryAgentPresetResponse: A response containing the list of presets. + """ + try: + return await db.list_presets( + user_id=user_id, + graph_id=graph_id, + page=page, + page_size=page_size, + ) + except Exception as e: + logger.exception("Failed to list presets for user %s: %s", user_id, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) + ) + + +@router.get( + "/presets/{preset_id}", + summary="Get a specific preset", + description="Retrieve details for a specific preset by its ID.", +) +async def get_preset( + preset_id: str, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> models.LibraryAgentPreset: + """ + Retrieve details for a specific preset by its ID. + + Args: + preset_id (str): ID of the preset to retrieve. + user_id (str): ID of the authenticated user. + + Returns: + models.LibraryAgentPreset: The preset details. + + Raises: + HTTPException: If the preset is not found or an error occurs. + """ + try: + preset = await db.get_preset(user_id, preset_id) + except Exception as e: + logger.exception( + "Error retrieving preset %s for user %s: %s", preset_id, user_id, e + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) + ) + + if not preset: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Preset #{preset_id} not found", + ) + return preset + + +@router.post( + "/presets", + summary="Create a new preset", + description="Create a new preset for the current user.", +) +async def create_preset( + preset: ( + models.LibraryAgentPresetCreatable + | models.LibraryAgentPresetCreatableFromGraphExecution + ), + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> models.LibraryAgentPreset: + """ + Create a new library agent preset. Automatically corrects node_input format if needed. + + Args: + preset (models.LibraryAgentPresetCreatable): The preset data to create. + user_id (str): ID of the authenticated user. + + Returns: + models.LibraryAgentPreset: The created preset. + + Raises: + HTTPException: If an error occurs while creating the preset. + """ + try: + if isinstance(preset, models.LibraryAgentPresetCreatable): + return await db.create_preset(user_id, preset) + else: + return await db.create_preset_from_graph_execution(user_id, preset) + except NotFoundError as e: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) + except Exception as e: + logger.exception("Preset creation failed for user %s: %s", user_id, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) + ) + + +@router.post("/presets/setup-trigger") +async def setup_trigger( + params: models.TriggeredPresetSetupRequest = Body(), + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> models.LibraryAgentPreset: + """ + Sets up a webhook-triggered `LibraryAgentPreset` for a `LibraryAgent`. + Returns the correspondingly created `LibraryAgentPreset` with `webhook_id` set. + """ + graph = await get_graph( + params.graph_id, version=params.graph_version, user_id=user_id + ) + if not graph: + raise HTTPException( + status.HTTP_410_GONE, + f"Graph #{params.graph_id} not accessible (anymore)", + ) + if not (trigger_node := graph.webhook_input_node): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Graph #{params.graph_id} does not have a webhook node", + ) + + trigger_config_with_credentials = { + **params.trigger_config, + **( + make_node_credentials_input_map(graph, params.agent_credentials).get( + trigger_node.id + ) + or {} + ), + } + + new_webhook, feedback = await setup_webhook_for_block( + user_id=user_id, + trigger_block=trigger_node.block, + trigger_config=trigger_config_with_credentials, + ) + if not new_webhook: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Could not set up webhook: {feedback}", + ) + + new_preset = await db.create_preset( + user_id=user_id, + preset=models.LibraryAgentPresetCreatable( + graph_id=params.graph_id, + graph_version=params.graph_version, + name=params.name, + description=params.description, + inputs=trigger_config_with_credentials, + credentials=params.agent_credentials, + webhook_id=new_webhook.id, + is_active=True, + ), + ) + return new_preset + + +@router.patch( + "/presets/{preset_id}", + summary="Update an existing preset", + description="Update an existing preset by its ID.", +) +async def update_preset( + preset_id: str, + preset: models.LibraryAgentPresetUpdatable, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> models.LibraryAgentPreset: + """ + Update an existing library agent preset. + + Args: + preset_id (str): ID of the preset to update. + preset (models.LibraryAgentPresetUpdatable): The preset data to update. + user_id (str): ID of the authenticated user. + + Returns: + models.LibraryAgentPreset: The updated preset. + + Raises: + HTTPException: If an error occurs while updating the preset. + """ + current = await get_preset(preset_id, user_id=user_id) + if not current: + raise HTTPException(status.HTTP_404_NOT_FOUND, f"Preset #{preset_id} not found") + + graph = await get_graph( + current.graph_id, + current.graph_version, + user_id=user_id, + ) + if not graph: + raise HTTPException( + status.HTTP_410_GONE, + f"Graph #{current.graph_id} not accessible (anymore)", + ) + + trigger_inputs_updated, new_webhook, feedback = False, None, None + if (trigger_node := graph.webhook_input_node) and ( + preset.inputs is not None and preset.credentials is not None + ): + trigger_config_with_credentials = { + **preset.inputs, + **( + make_node_credentials_input_map(graph, preset.credentials).get( + trigger_node.id + ) + or {} + ), + } + new_webhook, feedback = await setup_webhook_for_block( + user_id=user_id, + trigger_block=graph.webhook_input_node.block, + trigger_config=trigger_config_with_credentials, + for_preset_id=preset_id, + ) + trigger_inputs_updated = True + if not new_webhook: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Could not update trigger configuration: {feedback}", + ) + + try: + updated = await db.update_preset( + user_id=user_id, + preset_id=preset_id, + inputs=preset.inputs, + credentials=preset.credentials, + name=preset.name, + description=preset.description, + is_active=preset.is_active, + ) + except Exception as e: + logger.exception("Preset update failed for user %s: %s", user_id, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e) + ) + + # Update the webhook as well, if necessary + if trigger_inputs_updated: + updated = await db.set_preset_webhook( + user_id, preset_id, new_webhook.id if new_webhook else None + ) + + # Clean up webhook if it is now unused + if current.webhook_id and ( + current.webhook_id != (new_webhook.id if new_webhook else None) + ): + current_webhook = await get_webhook(current.webhook_id) + credentials = ( + await credentials_manager.get(user_id, current_webhook.credentials_id) + if current_webhook.credentials_id + else None + ) + await get_webhook_manager( + current_webhook.provider + ).prune_webhook_if_dangling(user_id, current_webhook.id, credentials) + + return updated + + +@router.delete( + "/presets/{preset_id}", + status_code=status.HTTP_204_NO_CONTENT, + summary="Delete a preset", + description="Delete an existing preset by its ID.", +) +async def delete_preset( + preset_id: str, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), +) -> None: + """ + Delete a preset by its ID. Returns 204 No Content on success. + + Args: + preset_id (str): ID of the preset to delete. + user_id (str): ID of the authenticated user. + + Raises: + HTTPException: If an error occurs while deleting the preset. + """ + preset = await db.get_preset(user_id, preset_id) + if not preset: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Preset #{preset_id} not found for user #{user_id}", + ) + + # Detach and clean up the attached webhook, if any + if preset.webhook_id: + webhook = await get_webhook(preset.webhook_id) + await db.set_preset_webhook(user_id, preset_id, None) + + # Clean up webhook if it is now unused + credentials = ( + await credentials_manager.get(user_id, webhook.credentials_id) + if webhook.credentials_id + else None + ) + await get_webhook_manager(webhook.provider).prune_webhook_if_dangling( + user_id, webhook.id, credentials + ) + + try: + await db.delete_preset(user_id, preset_id) + except Exception as e: + logger.exception( + "Error deleting preset %s for user %s: %s", preset_id, user_id, e + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) + + +@router.post( + "/presets/{preset_id}/execute", + tags=["presets"], + summary="Execute a preset", + description="Execute a preset with the given graph and node input for the current user.", +) +async def execute_preset( + preset_id: str, + user_id: str = Depends(autogpt_auth_lib.depends.get_user_id), + inputs: dict[str, Any] = Body(..., embed=True, default_factory=dict), +) -> dict[str, Any]: # FIXME: add proper return type + """ + Execute a preset given graph parameters, returning the execution ID on success. + + Args: + preset_id (str): ID of the preset to execute. + user_id (str): ID of the authenticated user. + inputs (dict[str, Any]): Optionally, additional input data for the graph execution. + + Returns: + {id: graph_exec_id}: A response containing the execution ID. + + Raises: + HTTPException: If the preset is not found or an error occurs while executing the preset. + """ + try: + preset = await db.get_preset(user_id, preset_id) + if not preset: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Preset #{preset_id} not found", + ) + + # Merge input overrides with preset inputs + merged_node_input = preset.inputs | inputs + + execution = await add_graph_execution( + user_id=user_id, + graph_id=preset.graph_id, + graph_version=preset.graph_version, + preset_id=preset_id, + inputs=merged_node_input, + ) + + logger.debug(f"Execution added: {execution} with input: {merged_node_input}") + + return {"id": execution.id} + except HTTPException: + raise + except Exception as e: + logger.exception("Preset execution failed for user %s: %s", user_id, e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=str(e), + ) diff --git a/autogpt_platform/backend/backend/server/v2/library/routes_test.py b/autogpt_platform/backend/backend/server/v2/library/routes_test.py index a48b416de437..2681e9dfb79c 100644 --- a/autogpt_platform/backend/backend/server/v2/library/routes_test.py +++ b/autogpt_platform/backend/backend/server/v2/library/routes_test.py @@ -1,18 +1,23 @@ -import autogpt_libs.auth.depends -import autogpt_libs.auth.middleware -import fastapi +import datetime +import json + +import autogpt_libs.auth as autogpt_auth_lib import fastapi.testclient +import pytest import pytest_mock +from pytest_snapshot.plugin import Snapshot -import backend.server.v2.library.db -import backend.server.v2.library.model -import backend.server.v2.library.routes +import backend.server.v2.library.model as library_model +from backend.server.v2.library.routes import router as library_router +from backend.util.models import Pagination app = fastapi.FastAPI() -app.include_router(backend.server.v2.library.routes.router) +app.include_router(library_router) client = fastapi.testclient.TestClient(app) +FIXED_NOW = datetime.datetime(2023, 1, 1, 0, 0, 0) + def override_auth_middleware(): """Override auth middleware for testing""" @@ -24,80 +29,152 @@ def override_get_user_id(): return "test-user-id" -app.dependency_overrides[autogpt_libs.auth.middleware.auth_middleware] = ( - override_auth_middleware -) -app.dependency_overrides[autogpt_libs.auth.depends.get_user_id] = override_get_user_id - - -def test_get_library_agents_success(mocker: pytest_mock.MockFixture): - mocked_value = [ - backend.server.v2.library.model.LibraryAgent( - id="test-agent-1", - version=1, - is_active=True, - name="Test Agent 1", - description="Test Description 1", - isCreatedByUser=True, - input_schema={"type": "object", "properties": {}}, - output_schema={"type": "object", "properties": {}}, - ), - backend.server.v2.library.model.LibraryAgent( - id="test-agent-2", - version=1, - is_active=True, - name="Test Agent 2", - description="Test Description 2", - isCreatedByUser=False, - input_schema={"type": "object", "properties": {}}, - output_schema={"type": "object", "properties": {}}, +app.dependency_overrides[autogpt_auth_lib.auth_middleware] = override_auth_middleware +app.dependency_overrides[autogpt_auth_lib.depends.get_user_id] = override_get_user_id + + +@pytest.mark.asyncio +async def test_get_library_agents_success( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + mocked_value = library_model.LibraryAgentResponse( + agents=[ + library_model.LibraryAgent( + id="test-agent-1", + graph_id="test-agent-1", + graph_version=1, + name="Test Agent 1", + description="Test Description 1", + image_url=None, + creator_name="Test Creator", + creator_image_url="", + input_schema={"type": "object", "properties": {}}, + output_schema={"type": "object", "properties": {}}, + credentials_input_schema={"type": "object", "properties": {}}, + has_external_trigger=False, + status=library_model.LibraryAgentStatus.COMPLETED, + new_output=False, + can_access_graph=True, + is_latest_version=True, + updated_at=datetime.datetime(2023, 1, 1, 0, 0, 0), + ), + library_model.LibraryAgent( + id="test-agent-2", + graph_id="test-agent-2", + graph_version=1, + name="Test Agent 2", + description="Test Description 2", + image_url=None, + creator_name="Test Creator", + creator_image_url="", + input_schema={"type": "object", "properties": {}}, + output_schema={"type": "object", "properties": {}}, + credentials_input_schema={"type": "object", "properties": {}}, + has_external_trigger=False, + status=library_model.LibraryAgentStatus.COMPLETED, + new_output=False, + can_access_graph=False, + is_latest_version=True, + updated_at=datetime.datetime(2023, 1, 1, 0, 0, 0), + ), + ], + pagination=Pagination( + total_items=2, total_pages=1, current_page=1, page_size=50 ), - ] - mock_db_call = mocker.patch("backend.server.v2.library.db.get_library_agents") + ) + mock_db_call = mocker.patch("backend.server.v2.library.db.list_library_agents") mock_db_call.return_value = mocked_value - response = client.get("/agents") + response = client.get("/agents?search_term=test") assert response.status_code == 200 - data = [ - backend.server.v2.library.model.LibraryAgent.model_validate(agent) - for agent in response.json() - ] - assert len(data) == 2 - assert data[0].id == "test-agent-1" - assert data[0].isCreatedByUser is True - assert data[1].id == "test-agent-2" - assert data[1].isCreatedByUser is False - mock_db_call.assert_called_once_with("test-user-id") + data = library_model.LibraryAgentResponse.model_validate(response.json()) + assert len(data.agents) == 2 + assert data.agents[0].graph_id == "test-agent-1" + assert data.agents[0].can_access_graph is True + assert data.agents[1].graph_id == "test-agent-2" + assert data.agents[1].can_access_graph is False + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "lib_agts_search") + + mock_db_call.assert_called_once_with( + user_id="test-user-id", + search_term="test", + sort_by=library_model.LibraryAgentSort.UPDATED_AT, + page=1, + page_size=15, + ) def test_get_library_agents_error(mocker: pytest_mock.MockFixture): - mock_db_call = mocker.patch("backend.server.v2.library.db.get_library_agents") + mock_db_call = mocker.patch("backend.server.v2.library.db.list_library_agents") mock_db_call.side_effect = Exception("Test error") - response = client.get("/agents") + response = client.get("/agents?search_term=test") assert response.status_code == 500 - mock_db_call.assert_called_once_with("test-user-id") + mock_db_call.assert_called_once_with( + user_id="test-user-id", + search_term="test", + sort_by=library_model.LibraryAgentSort.UPDATED_AT, + page=1, + page_size=15, + ) def test_add_agent_to_library_success(mocker: pytest_mock.MockFixture): - mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library") - mock_db_call.return_value = None + mock_library_agent = library_model.LibraryAgent( + id="test-library-agent-id", + graph_id="test-agent-1", + graph_version=1, + name="Test Agent 1", + description="Test Description 1", + image_url=None, + creator_name="Test Creator", + creator_image_url="", + input_schema={"type": "object", "properties": {}}, + output_schema={"type": "object", "properties": {}}, + credentials_input_schema={"type": "object", "properties": {}}, + has_external_trigger=False, + status=library_model.LibraryAgentStatus.COMPLETED, + new_output=False, + can_access_graph=True, + is_latest_version=True, + updated_at=FIXED_NOW, + ) + + mock_db_call = mocker.patch( + "backend.server.v2.library.db.add_store_agent_to_library" + ) + mock_db_call.return_value = mock_library_agent - response = client.post("/agents/test-version-id") + response = client.post( + "/agents", json={"store_listing_version_id": "test-version-id"} + ) assert response.status_code == 201 + + # Verify the response contains the library agent data + data = library_model.LibraryAgent.model_validate(response.json()) + assert data.id == "test-library-agent-id" + assert data.graph_id == "test-agent-1" + mock_db_call.assert_called_once_with( store_listing_version_id="test-version-id", user_id="test-user-id" ) def test_add_agent_to_library_error(mocker: pytest_mock.MockFixture): - mock_db_call = mocker.patch("backend.server.v2.library.db.add_agent_to_library") + mock_db_call = mocker.patch( + "backend.server.v2.library.db.add_store_agent_to_library" + ) mock_db_call.side_effect = Exception("Test error") - response = client.post("/agents/test-version-id") + response = client.post( + "/agents", json={"store_listing_version_id": "test-version-id"} + ) assert response.status_code == 500 - assert response.json()["detail"] == "Failed to add agent to library" + assert "detail" in response.json() # Verify error response structure mock_db_call.assert_called_once_with( store_listing_version_id="test-version-id", user_id="test-user-id" ) diff --git a/autogpt_platform/backend/backend/server/v2/otto/models.py b/autogpt_platform/backend/backend/server/v2/otto/models.py new file mode 100644 index 000000000000..6f50a8fea4d8 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/otto/models.py @@ -0,0 +1,34 @@ +from typing import Any, Dict, Optional + +from pydantic import BaseModel + + +class Document(BaseModel): + url: str + relevance_score: float + + +class ApiResponse(BaseModel): + answer: str + documents: list[Document] + success: bool + + +class GraphData(BaseModel): + nodes: list[Dict[str, Any]] + edges: list[Dict[str, Any]] + graph_name: Optional[str] = None + graph_description: Optional[str] = None + + +class Message(BaseModel): + query: str + response: str + + +class ChatRequest(BaseModel): + query: str + conversation_history: list[Message] + message_id: str + include_graph_data: bool = False + graph_id: Optional[str] = None diff --git a/autogpt_platform/backend/backend/server/v2/otto/routes.py b/autogpt_platform/backend/backend/server/v2/otto/routes.py new file mode 100644 index 000000000000..0e2231f4bc25 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/otto/routes.py @@ -0,0 +1,37 @@ +import logging + +from autogpt_libs.auth.middleware import auth_middleware +from fastapi import APIRouter, Depends, HTTPException + +from backend.server.utils import get_user_id + +from .models import ApiResponse, ChatRequest +from .service import OttoService + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post( + "/ask", + response_model=ApiResponse, + dependencies=[Depends(auth_middleware)], + summary="Proxy Otto Chat Request", +) +async def proxy_otto_request( + request: ChatRequest, user_id: str = Depends(get_user_id) +) -> ApiResponse: + """ + Proxy requests to Otto API while adding necessary security headers and logging. + Requires an authenticated user. + """ + logger.debug("Forwarding request to Otto for user %s", user_id) + try: + return await OttoService.ask(request, user_id) + except Exception as e: + logger.exception("Otto request failed for user %s: %s", user_id, e) + raise HTTPException( + status_code=502, + detail={"message": str(e), "hint": "Check Otto service status."}, + ) diff --git a/autogpt_platform/backend/backend/server/v2/otto/routes_test.py b/autogpt_platform/backend/backend/server/v2/otto/routes_test.py new file mode 100644 index 000000000000..861ee7a4f6ac --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/otto/routes_test.py @@ -0,0 +1,271 @@ +import json + +import autogpt_libs.auth.depends +import autogpt_libs.auth.middleware +import fastapi +import fastapi.testclient +import pytest_mock +from pytest_snapshot.plugin import Snapshot + +import backend.server.v2.otto.models as otto_models +import backend.server.v2.otto.routes as otto_routes +from backend.server.utils import get_user_id +from backend.server.v2.otto.service import OttoService + +app = fastapi.FastAPI() +app.include_router(otto_routes.router) + +client = fastapi.testclient.TestClient(app) + + +def override_auth_middleware(): + """Override auth middleware for testing""" + return {"sub": "test-user-id"} + + +def override_get_user_id(): + """Override get_user_id for testing""" + return "test-user-id" + + +app.dependency_overrides[autogpt_libs.auth.middleware.auth_middleware] = ( + override_auth_middleware +) +app.dependency_overrides[get_user_id] = override_get_user_id + + +def test_ask_otto_success( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test successful Otto API request""" + # Mock the OttoService.ask method + mock_response = otto_models.ApiResponse( + answer="This is Otto's response to your query.", + documents=[ + otto_models.Document( + url="https://example.com/doc1", + relevance_score=0.95, + ), + otto_models.Document( + url="https://example.com/doc2", + relevance_score=0.87, + ), + ], + success=True, + ) + + mocker.patch.object( + OttoService, + "ask", + return_value=mock_response, + ) + + request_data = { + "query": "How do I create an agent?", + "conversation_history": [ + { + "query": "What is AutoGPT?", + "response": "AutoGPT is an AI agent platform.", + } + ], + "message_id": "msg_123", + "include_graph_data": False, + } + + response = client.post("/ask", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert response_data["answer"] == "This is Otto's response to your query." + assert len(response_data["documents"]) == 2 + + # Snapshot test the response + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "otto_ok", + ) + + +def test_ask_otto_with_graph_data( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test Otto API request with graph data included""" + # Mock the OttoService.ask method + mock_response = otto_models.ApiResponse( + answer="Here's information about your graph.", + documents=[ + otto_models.Document( + url="https://example.com/graph-doc", + relevance_score=0.92, + ), + ], + success=True, + ) + + mocker.patch.object( + OttoService, + "ask", + return_value=mock_response, + ) + + request_data = { + "query": "Tell me about my graph", + "conversation_history": [], + "message_id": "msg_456", + "include_graph_data": True, + "graph_id": "graph_123", + } + + response = client.post("/ask", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + + # Snapshot test the response + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "otto_grph", + ) + + +def test_ask_otto_empty_conversation( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test Otto API request with empty conversation history""" + # Mock the OttoService.ask method + mock_response = otto_models.ApiResponse( + answer="Welcome! How can I help you?", + documents=[], + success=True, + ) + + mocker.patch.object( + OttoService, + "ask", + return_value=mock_response, + ) + + request_data = { + "query": "Hello", + "conversation_history": [], + "message_id": "msg_789", + "include_graph_data": False, + } + + response = client.post("/ask", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is True + assert len(response_data["documents"]) == 0 + + # Snapshot test the response + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "otto_empty", + ) + + +def test_ask_otto_service_error( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: + """Test Otto API request when service returns error""" + # Mock the OttoService.ask method to return failure + mock_response = otto_models.ApiResponse( + answer="An error occurred while processing your request.", + documents=[], + success=False, + ) + + mocker.patch.object( + OttoService, + "ask", + return_value=mock_response, + ) + + request_data = { + "query": "Test query", + "conversation_history": [], + "message_id": "msg_error", + "include_graph_data": False, + } + + response = client.post("/ask", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is False + + # Snapshot test the response + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match( + json.dumps(response_data, indent=2, sort_keys=True), + "otto_err", + ) + + +def test_ask_otto_invalid_request() -> None: + """Test Otto API with invalid request data""" + # Missing required fields + response = client.post("/ask", json={}) + assert response.status_code == 422 + + # Invalid conversation history format + response = client.post( + "/ask", + json={ + "query": "Test", + "conversation_history": "not a list", + "message_id": "123", + }, + ) + assert response.status_code == 422 + + # Missing message_id + response = client.post( + "/ask", + json={ + "query": "Test", + "conversation_history": [], + }, + ) + assert response.status_code == 422 + + +def test_ask_otto_unauthenticated(mocker: pytest_mock.MockFixture) -> None: + """Test Otto API request without authentication""" + # Remove the auth override to test unauthenticated access + app.dependency_overrides.clear() + + # Mock auth_middleware to raise an exception + mocker.patch( + "autogpt_libs.auth.middleware.auth_middleware", + side_effect=fastapi.HTTPException(status_code=401, detail="Unauthorized"), + ) + + request_data = { + "query": "Test", + "conversation_history": [], + "message_id": "123", + } + + response = client.post("/ask", json=request_data) + # When auth is disabled and Otto API URL is not configured, we get 502 (wrapped from 503) + assert response.status_code == 502 + + # Restore the override + app.dependency_overrides[autogpt_libs.auth.middleware.auth_middleware] = ( + override_auth_middleware + ) + app.dependency_overrides[autogpt_libs.auth.depends.get_user_id] = ( + override_get_user_id + ) diff --git a/autogpt_platform/backend/backend/server/v2/otto/service.py b/autogpt_platform/backend/backend/server/v2/otto/service.py new file mode 100644 index 000000000000..8efa4f642fbc --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/otto/service.py @@ -0,0 +1,138 @@ +import asyncio +import logging +from typing import Optional + +import aiohttp +from fastapi import HTTPException + +from backend.data import graph as graph_db +from backend.data.block import get_block +from backend.util.settings import Settings + +from .models import ApiResponse, ChatRequest, GraphData + +logger = logging.getLogger(__name__) +settings = Settings() + +OTTO_API_URL = settings.config.otto_api_url + + +class OttoService: + @staticmethod + async def _fetch_graph_data( + request: ChatRequest, user_id: str + ) -> Optional[GraphData]: + """Fetch graph data if requested and available.""" + if not (request.include_graph_data and request.graph_id): + return None + + try: + graph = await graph_db.get_graph(request.graph_id, user_id=user_id) + if not graph: + return None + + nodes_data = [] + for node in graph.nodes: + block = get_block(node.block_id) + if not block: + continue + + node_data = { + "id": node.id, + "block_id": node.block_id, + "block_name": block.name, + "block_type": ( + block.block_type.value if hasattr(block, "block_type") else None + ), + "data": { + k: v + for k, v in (node.input_default or {}).items() + if k not in ["credentials"] # Exclude sensitive data + }, + } + nodes_data.append(node_data) + + # Create a GraphData object with the required fields + return GraphData( + nodes=nodes_data, + edges=[], + graph_name=graph.name, + graph_description=graph.description, + ) + except Exception as e: + logger.error(f"Failed to fetch graph data: {str(e)}") + return None + + @staticmethod + async def ask(request: ChatRequest, user_id: str) -> ApiResponse: + """ + Send request to Otto API and handle the response. + """ + # Check if Otto API URL is configured + if not OTTO_API_URL: + logger.error("Otto API URL is not configured") + raise HTTPException( + status_code=503, detail="Otto service is not configured" + ) + + try: + async with aiohttp.ClientSession() as session: + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + } + + # If graph data is requested, fetch it + graph_data = await OttoService._fetch_graph_data(request, user_id) + + # Prepare the payload with optional graph data + payload = { + "query": request.query, + "conversation_history": [ + msg.model_dump() for msg in request.conversation_history + ], + "user_id": user_id, + "message_id": request.message_id, + } + + if graph_data: + payload["graph_data"] = graph_data.model_dump() + + logger.info(f"Sending request to Otto API for user {user_id}") + logger.debug(f"Request payload: {payload}") + + async with session.post( + OTTO_API_URL, + json=payload, + headers=headers, + timeout=aiohttp.ClientTimeout(total=60), + ) as response: + if response.status != 200: + error_text = await response.text() + logger.error(f"Otto API error: {error_text}") + raise HTTPException( + status_code=response.status, + detail=f"Otto API request failed: {error_text}", + ) + + data = await response.json() + logger.info( + f"Successfully received response from Otto API for user {user_id}" + ) + return ApiResponse(**data) + + except aiohttp.ClientError as e: + logger.error(f"Connection error to Otto API: {str(e)}") + raise HTTPException( + status_code=503, detail="Failed to connect to Otto service" + ) + except asyncio.TimeoutError: + logger.error("Timeout error connecting to Otto API after 60 seconds") + raise HTTPException( + status_code=504, detail="Request to Otto service timed out" + ) + except Exception as e: + logger.error(f"Unexpected error in Otto API proxy: {str(e)}") + raise HTTPException( + status_code=500, detail="Internal server error in Otto proxy" + ) diff --git a/autogpt_platform/backend/backend/server/v2/store/db.py b/autogpt_platform/backend/backend/server/v2/store/db.py index f8ffad0cf376..d50bf5c90968 100644 --- a/autogpt_platform/backend/backend/server/v2/store/db.py +++ b/autogpt_platform/backend/backend/server/v2/store/db.py @@ -1,7 +1,7 @@ import logging -import random -from datetime import datetime +from datetime import datetime, timezone +import fastapi import prisma.enums import prisma.errors import prisma.models @@ -9,51 +9,72 @@ import backend.server.v2.store.exceptions import backend.server.v2.store.model +from backend.data.graph import ( + GraphMeta, + GraphModel, + get_graph, + get_graph_as_admin, + get_sub_graphs, +) +from backend.data.includes import AGENT_GRAPH_INCLUDE +from backend.data.notifications import ( + AgentApprovalData, + AgentRejectionData, + NotificationEventModel, +) +from backend.notifications.notifications import queue_notification_async +from backend.util.settings import Settings logger = logging.getLogger(__name__) +settings = Settings() + + +# Constants for default admin values +DEFAULT_ADMIN_NAME = "AutoGPT Admin" +DEFAULT_ADMIN_EMAIL = "admin@autogpt.co" + + +def sanitize_query(query: str | None) -> str | None: + if query is None: + return query + query = query.strip()[:100] + return ( + query.replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + .replace("[", "\\[") + .replace("]", "\\]") + .replace("'", "\\'") + .replace('"', '\\"') + .replace(";", "\\;") + .replace("--", "\\--") + .replace("/*", "\\/*") + .replace("*/", "\\*/") + ) async def get_store_agents( featured: bool = False, - creator: str | None = None, + creators: list[str] | None = None, sorted_by: str | None = None, search_query: str | None = None, category: str | None = None, page: int = 1, page_size: int = 20, ) -> backend.server.v2.store.model.StoreAgentsResponse: + """ + Get PUBLIC store agents from the StoreAgent view + """ logger.debug( - f"Getting store agents. featured={featured}, creator={creator}, sorted_by={sorted_by}, search={search_query}, category={category}, page={page}" + f"Getting store agents. featured={featured}, creators={creators}, sorted_by={sorted_by}, search={search_query}, category={category}, page={page}" ) - sanitized_query = None - # Sanitize and validate search query by escaping special characters - if search_query is not None: - sanitized_query = search_query.strip() - if not sanitized_query or len(sanitized_query) > 100: # Reasonable length limit - raise backend.server.v2.store.exceptions.DatabaseError( - "Invalid search query" - ) - - # Escape special SQL characters - sanitized_query = ( - sanitized_query.replace("\\", "\\\\") - .replace("%", "\\%") - .replace("_", "\\_") - .replace("[", "\\[") - .replace("]", "\\]") - .replace("'", "\\'") - .replace('"', '\\"') - .replace(";", "\\;") - .replace("--", "\\--") - .replace("/*", "\\/*") - .replace("*/", "\\*/") - ) + sanitized_query = sanitize_query(search_query) where_clause = {} if featured: where_clause["featured"] = featured - if creator: - where_clause["creator_username"] = creator + if creators: + where_clause["creator_username"] = {"in": creators} if category: where_clause["categories"] = {"has": category} @@ -84,20 +105,30 @@ async def get_store_agents( ) total_pages = (total + page_size - 1) // page_size - store_agents = [ - backend.server.v2.store.model.StoreAgent( - slug=agent.slug, - agent_name=agent.agent_name, - agent_image=agent.agent_image[0] if agent.agent_image else "", - creator=agent.creator_username, - creator_avatar=agent.creator_avatar, - sub_heading=agent.sub_heading, - description=agent.description, - runs=agent.runs, - rating=agent.rating, - ) - for agent in agents - ] + store_agents: list[backend.server.v2.store.model.StoreAgent] = [] + for agent in agents: + try: + # Create the StoreAgent object safely + store_agent = backend.server.v2.store.model.StoreAgent( + slug=agent.slug, + agent_name=agent.agent_name, + agent_image=agent.agent_image[0] if agent.agent_image else "", + creator=agent.creator_username or "Needs Profile", + creator_avatar=agent.creator_avatar or "", + sub_heading=agent.sub_heading, + description=agent.description, + runs=agent.runs, + rating=agent.rating, + ) + # Add to the list only if creation was successful + store_agents.append(store_agent) + except Exception as e: + # Skip this agent if there was an error + # You could log the error here if needed + logger.error( + f"Error parsing Store agent when getting store agents from db: {e}" + ) + continue logger.debug(f"Found {len(store_agents)} agents") return backend.server.v2.store.model.StoreAgentsResponse( @@ -110,7 +141,7 @@ async def get_store_agents( ), ) except Exception as e: - logger.error(f"Error getting store agents: {str(e)}") + logger.error(f"Error getting store agents: {e}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to fetch store agents" ) from e @@ -119,6 +150,7 @@ async def get_store_agents( async def get_store_agent_details( username: str, agent_name: str ) -> backend.server.v2.store.model.StoreAgentDetails: + """Get PUBLIC store agent details from the StoreAgent view""" logger.debug(f"Getting store agent details for {username}/{agent_name}") try: @@ -132,6 +164,25 @@ async def get_store_agent_details( f"Agent {username}/{agent_name} not found" ) + profile = await prisma.models.Profile.prisma().find_first( + where={"username": username} + ) + user_id = profile.userId if profile else None + + # Retrieve StoreListing to get active_version_id and has_approved_version + store_listing = await prisma.models.StoreListing.prisma().find_first( + where=prisma.types.StoreListingWhereInput( + slug=agent_name, + owningUserId=user_id or "", + ), + include={"ActiveVersion": True}, + ) + + active_version_id = store_listing.activeVersionId if store_listing else None + has_approved_version = ( + store_listing.hasApprovedVersion if store_listing else False + ) + logger.debug(f"Found agent details for {username}/{agent_name}") return backend.server.v2.store.model.StoreAgentDetails( store_listing_version_id=agent.storeListingVersionId, @@ -148,11 +199,84 @@ async def get_store_agent_details( rating=agent.rating, versions=agent.versions, last_updated=agent.updated_at, + active_version_id=active_version_id, + has_approved_version=has_approved_version, ) except backend.server.v2.store.exceptions.AgentNotFoundError: raise except Exception as e: - logger.error(f"Error getting store agent details: {str(e)}") + logger.error(f"Error getting store agent details: {e}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to fetch agent details" + ) from e + + +async def get_available_graph(store_listing_version_id: str) -> GraphMeta: + try: + # Get avaialble, non-deleted store listing version + store_listing_version = ( + await prisma.models.StoreListingVersion.prisma().find_first( + where={ + "id": store_listing_version_id, + "isAvailable": True, + "isDeleted": False, + }, + include={"AgentGraph": {"include": {"Nodes": True}}}, + ) + ) + + if not store_listing_version or not store_listing_version.AgentGraph: + raise fastapi.HTTPException( + status_code=404, + detail=f"Store listing version {store_listing_version_id} not found", + ) + + return GraphModel.from_db(store_listing_version.AgentGraph).meta() + + except Exception as e: + logger.error(f"Error getting agent: {e}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to fetch agent" + ) from e + + +async def get_store_agent_by_version_id( + store_listing_version_id: str, +) -> backend.server.v2.store.model.StoreAgentDetails: + logger.debug(f"Getting store agent details for {store_listing_version_id}") + + try: + agent = await prisma.models.StoreAgent.prisma().find_first( + where={"storeListingVersionId": store_listing_version_id} + ) + + if not agent: + logger.warning(f"Agent not found: {store_listing_version_id}") + raise backend.server.v2.store.exceptions.AgentNotFoundError( + f"Agent {store_listing_version_id} not found" + ) + + logger.debug(f"Found agent details for {store_listing_version_id}") + return backend.server.v2.store.model.StoreAgentDetails( + store_listing_version_id=agent.storeListingVersionId, + slug=agent.slug, + agent_name=agent.agent_name, + agent_video=agent.agent_video or "", + agent_image=agent.agent_image, + creator=agent.creator_username, + creator_avatar=agent.creator_avatar, + sub_heading=agent.sub_heading, + description=agent.description, + categories=agent.categories, + runs=agent.runs, + rating=agent.rating, + versions=agent.versions, + last_updated=agent.updated_at, + ) + except backend.server.v2.store.exceptions.AgentNotFoundError: + raise + except Exception as e: + logger.error(f"Error getting store agent details: {e}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to fetch agent details" ) from e @@ -165,6 +289,7 @@ async def get_store_creators( page: int = 1, page_size: int = 20, ) -> backend.server.v2.store.model.CreatorsResponse: + """Get PUBLIC store creators from the Creator view""" logger.debug( f"Getting store creators. featured={featured}, search={search_query}, sorted_by={sorted_by}, page={page}" ) @@ -266,7 +391,7 @@ async def get_store_creators( ), ) except Exception as e: - logger.error(f"Error getting store creators: {str(e)}") + logger.error(f"Error getting store creators: {e}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to fetch store creators" ) from e @@ -303,7 +428,7 @@ async def get_store_creator_details( except backend.server.v2.store.exceptions.CreatorNotFoundError: raise except Exception as e: - logger.error(f"Error getting store creator details: {str(e)}") + logger.error(f"Error getting store creator details: {e}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to fetch creator details" ) from e @@ -312,6 +437,7 @@ async def get_store_creator_details( async def get_store_submissions( user_id: str, page: int = 1, page_size: int = 20 ) -> backend.server.v2.store.model.StoreSubmissionsResponse: + """Get store submissions for the authenticated user -- not an admin""" logger.debug(f"Getting store submissions for user {user_id}, page={page}") try: @@ -321,7 +447,10 @@ async def get_store_submissions( where = prisma.types.StoreSubmissionWhereInput(user_id=user_id) # Query submissions from database submissions = await prisma.models.StoreSubmission.prisma().find_many( - where=where, skip=skip, take=page_size, order=[{"date_submitted": "desc"}] + where=where, + skip=skip, + take=page_size, + order=[{"date_submitted": "desc"}], ) # Get total count for pagination @@ -330,8 +459,9 @@ async def get_store_submissions( total_pages = (total + page_size - 1) // page_size # Convert to response models - submission_models = [ - backend.server.v2.store.model.StoreSubmission( + submission_models = [] + for sub in submissions: + submission_model = backend.server.v2.store.model.StoreSubmission( agent_id=sub.agent_id, agent_version=sub.agent_version, name=sub.name, @@ -339,13 +469,20 @@ async def get_store_submissions( slug=sub.slug, description=sub.description, image_urls=sub.image_urls or [], - date_submitted=sub.date_submitted or datetime.now(), + date_submitted=sub.date_submitted or datetime.now(tz=timezone.utc), status=sub.status, runs=sub.runs or 0, rating=sub.rating or 0.0, + store_listing_version_id=sub.store_listing_version_id, + reviewer_id=sub.reviewer_id, + review_comments=sub.review_comments, + # internal_comments omitted for regular users + reviewed_at=sub.reviewed_at, + changes_summary=sub.changes_summary, + video_url=sub.video_url, + categories=sub.categories, ) - for sub in submissions - ] + submission_models.append(submission_model) logger.debug(f"Found {len(submission_models)} submissions") return backend.server.v2.store.model.StoreSubmissionsResponse( @@ -359,7 +496,7 @@ async def get_store_submissions( ) except Exception as e: - logger.error(f"Error fetching store submissions: {str(e)}") + logger.error(f"Error fetching store submissions: {e}") # Return empty response rather than exposing internal errors return backend.server.v2.store.model.StoreSubmissionsResponse( submissions=[], @@ -377,7 +514,7 @@ async def delete_store_submission( submission_id: str, ) -> bool: """ - Delete a store listing submission. + Delete a store listing submission as the submitting user. Args: user_id: ID of the authenticated user @@ -391,7 +528,7 @@ async def delete_store_submission( try: # Verify the submission belongs to this user submission = await prisma.models.StoreListing.prisma().find_first( - where={"agentId": submission_id, "owningUserId": user_id} + where={"agentGraphId": submission_id, "owningUserId": user_id} ) if not submission: @@ -401,9 +538,7 @@ async def delete_store_submission( ) # Delete the submission - await prisma.models.StoreListing.prisma().delete( - where=prisma.types.StoreListingWhereUniqueInput(id=submission.id) - ) + await prisma.models.StoreListing.prisma().delete(where={"id": submission.id}) logger.debug( f"Successfully deleted submission {submission_id} for user {user_id}" @@ -411,7 +546,7 @@ async def delete_store_submission( return True except Exception as e: - logger.error(f"Error deleting store submission: {str(e)}") + logger.error(f"Error deleting store submission: {e}") return False @@ -426,9 +561,10 @@ async def create_store_submission( description: str = "", sub_heading: str = "", categories: list[str] = [], + changes_summary: str | None = "Initial Submission", ) -> backend.server.v2.store.model.StoreSubmission: """ - Create a new store listing submission. + Create the first (and only) store listing and thus submission as a normal user Args: user_id: ID of the authenticated user submitting the listing @@ -439,7 +575,9 @@ async def create_store_submission( video_url: Optional URL to video demo image_urls: List of image URLs for the listing description: Description of the agent + sub_heading: Optional sub-heading for the agent categories: List of categories for the agent + changes_summary: Summary of changes made in this submission Returns: StoreSubmission: The created store submission @@ -449,6 +587,11 @@ async def create_store_submission( ) try: + # Sanitize slug to only allow letters and hyphens + slug = "".join( + c if c.isalpha() or c == "-" or c.isnumeric() else "" for c in slug + ).lower() + # First verify the agent belongs to this user agent = await prisma.models.AgentGraph.prisma().find_first( where=prisma.types.AgentGraphWhereInput( @@ -464,38 +607,67 @@ async def create_store_submission( f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}" ) - listing = await prisma.models.StoreListing.prisma().find_first( + # Check if listing already exists for this agent + existing_listing = await prisma.models.StoreListing.prisma().find_first( where=prisma.types.StoreListingWhereInput( - agentId=agent_id, owningUserId=user_id + agentGraphId=agent_id, owningUserId=user_id ) ) - if listing is not None: - logger.warning(f"Listing already exists for agent {agent_id}") - raise backend.server.v2.store.exceptions.ListingExistsError( - "Listing already exists for this agent" + + if existing_listing is not None: + logger.info( + f"Listing already exists for agent {agent_id}, creating new version instead" + ) + + # Delegate to create_store_version which already handles this case correctly + return await create_store_version( + user_id=user_id, + agent_id=agent_id, + agent_version=agent_version, + store_listing_id=existing_listing.id, + name=name, + video_url=video_url, + image_urls=image_urls, + description=description, + sub_heading=sub_heading, + categories=categories, + changes_summary=changes_summary, ) - # Create the store listing + # If no existing listing, create a new one + data = prisma.types.StoreListingCreateInput( + slug=slug, + agentGraphId=agent_id, + agentGraphVersion=agent_version, + owningUserId=user_id, + createdAt=datetime.now(tz=timezone.utc), + Versions={ + "create": [ + prisma.types.StoreListingVersionCreateInput( + agentGraphId=agent_id, + agentGraphVersion=agent_version, + name=name, + videoUrl=video_url, + imageUrls=image_urls, + description=description, + categories=categories, + subHeading=sub_heading, + submissionStatus=prisma.enums.SubmissionStatus.PENDING, + submittedAt=datetime.now(tz=timezone.utc), + changesSummary=changes_summary, + ) + ] + }, + ) listing = await prisma.models.StoreListing.prisma().create( - data={ - "agentId": agent_id, - "agentVersion": agent_version, - "owningUserId": user_id, - "createdAt": datetime.now(), - "StoreListingVersions": { - "create": { - "agentId": agent_id, - "agentVersion": agent_version, - "slug": slug, - "name": name, - "videoUrl": video_url, - "imageUrls": image_urls, - "description": description, - "categories": categories, - "subHeading": sub_heading, - } - }, - } + data=data, + include=prisma.types.StoreListingInclude(Versions=True), + ) + + store_listing_version_id = ( + listing.Versions[0].id + if listing.Versions is not None and len(listing.Versions) > 0 + else None ) logger.debug(f"Created store listing for agent {agent_id}") @@ -512,6 +684,8 @@ async def create_store_submission( status=prisma.enums.SubmissionStatus.PENDING, runs=0, rating=0.0, + store_listing_version_id=store_listing_version_id, + changes_summary=changes_summary, ) except ( @@ -520,19 +694,298 @@ async def create_store_submission( ): raise except prisma.errors.PrismaError as e: - logger.error(f"Database error creating store submission: {str(e)}") + logger.error(f"Database error creating store submission: {e}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to create store submission" ) from e +async def edit_store_submission( + user_id: str, + store_listing_version_id: str, + name: str, + video_url: str | None = None, + image_urls: list[str] = [], + description: str = "", + sub_heading: str = "", + categories: list[str] = [], + changes_summary: str | None = "Update submission", +) -> backend.server.v2.store.model.StoreSubmission: + """ + Edit an existing store listing submission. + + Args: + user_id: ID of the authenticated user editing the submission + store_listing_version_id: ID of the store listing version to edit + agent_id: ID of the agent being submitted + agent_version: Version of the agent being submitted + slug: URL slug for the listing (only changeable for PENDING submissions) + name: Name of the agent + video_url: Optional URL to video demo + image_urls: List of image URLs for the listing + description: Description of the agent + sub_heading: Optional sub-heading for the agent + categories: List of categories for the agent + changes_summary: Summary of changes made in this submission + + Returns: + StoreSubmission: The updated store submission + + Raises: + SubmissionNotFoundError: If the submission is not found + UnauthorizedError: If the user doesn't own the submission + InvalidOperationError: If trying to edit a submission that can't be edited + """ + try: + # Get the current version and verify ownership + current_version = await prisma.models.StoreListingVersion.prisma().find_first( + where=prisma.types.StoreListingVersionWhereInput( + id=store_listing_version_id + ), + include={ + "StoreListing": { + "include": { + "Versions": {"order_by": {"version": "desc"}, "take": 1} + } + } + }, + ) + + if not current_version: + raise backend.server.v2.store.exceptions.SubmissionNotFoundError( + f"Store listing version not found: {store_listing_version_id}" + ) + + # Verify the user owns this submission + if ( + not current_version.StoreListing + or current_version.StoreListing.owningUserId != user_id + ): + raise backend.server.v2.store.exceptions.UnauthorizedError( + f"User {user_id} does not own submission {store_listing_version_id}" + ) + + # Currently we are not allowing user to update the agent associated with a submission + # If we allow it in future, then we need a check here to verify the agent belongs to this user. + + # Check if we can edit this submission + if current_version.submissionStatus == prisma.enums.SubmissionStatus.REJECTED: + raise backend.server.v2.store.exceptions.InvalidOperationError( + "Cannot edit a rejected submission" + ) + + # For APPROVED submissions, we need to create a new version + if current_version.submissionStatus == prisma.enums.SubmissionStatus.APPROVED: + # Create a new version for the existing listing + return await create_store_version( + user_id=user_id, + agent_id=current_version.agentGraphId, + agent_version=current_version.agentGraphVersion, + store_listing_id=current_version.storeListingId, + name=name, + video_url=video_url, + image_urls=image_urls, + description=description, + sub_heading=sub_heading, + categories=categories, + changes_summary=changes_summary, + ) + + # For PENDING submissions, we can update the existing version + elif current_version.submissionStatus == prisma.enums.SubmissionStatus.PENDING: + # Update the existing version + updated_version = await prisma.models.StoreListingVersion.prisma().update( + where={"id": store_listing_version_id}, + data=prisma.types.StoreListingVersionUpdateInput( + name=name, + videoUrl=video_url, + imageUrls=image_urls, + description=description, + categories=categories, + subHeading=sub_heading, + changesSummary=changes_summary, + ), + ) + + logger.debug( + f"Updated existing version {store_listing_version_id} for agent {current_version.agentGraphId}" + ) + + if not updated_version: + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to update store listing version" + ) + return backend.server.v2.store.model.StoreSubmission( + agent_id=current_version.agentGraphId, + agent_version=current_version.agentGraphVersion, + name=name, + sub_heading=sub_heading, + slug=current_version.StoreListing.slug, + description=description, + image_urls=image_urls, + date_submitted=updated_version.submittedAt or updated_version.createdAt, + status=updated_version.submissionStatus, + runs=0, + rating=0.0, + store_listing_version_id=updated_version.id, + changes_summary=changes_summary, + video_url=video_url, + categories=categories, + version=updated_version.version, + ) + + else: + raise backend.server.v2.store.exceptions.InvalidOperationError( + f"Cannot edit submission with status: {current_version.submissionStatus}" + ) + + except ( + backend.server.v2.store.exceptions.SubmissionNotFoundError, + backend.server.v2.store.exceptions.UnauthorizedError, + backend.server.v2.store.exceptions.AgentNotFoundError, + backend.server.v2.store.exceptions.ListingExistsError, + backend.server.v2.store.exceptions.InvalidOperationError, + ): + raise + except prisma.errors.PrismaError as e: + logger.error(f"Database error editing store submission: {e}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to edit store submission" + ) from e + + +async def create_store_version( + user_id: str, + agent_id: str, + agent_version: int, + store_listing_id: str, + name: str, + video_url: str | None = None, + image_urls: list[str] = [], + description: str = "", + sub_heading: str = "", + categories: list[str] = [], + changes_summary: str | None = "Initial submission", +) -> backend.server.v2.store.model.StoreSubmission: + """ + Create a new version for an existing store listing + + Args: + user_id: ID of the authenticated user submitting the version + agent_id: ID of the agent being submitted + agent_version: Version of the agent being submitted + store_listing_id: ID of the existing store listing + name: Name of the agent + video_url: Optional URL to video demo + image_urls: List of image URLs for the listing + description: Description of the agent + categories: List of categories for the agent + changes_summary: Summary of changes from the previous version + + Returns: + StoreSubmission: The created store submission + """ + logger.debug( + f"Creating new version for store listing {store_listing_id} for user {user_id}, agent {agent_id} v{agent_version}" + ) + + try: + # First verify the listing belongs to this user + listing = await prisma.models.StoreListing.prisma().find_first( + where=prisma.types.StoreListingWhereInput( + id=store_listing_id, owningUserId=user_id + ), + include={"Versions": {"order_by": {"version": "desc"}, "take": 1}}, + ) + + if not listing: + raise backend.server.v2.store.exceptions.ListingNotFoundError( + f"Store listing not found. User ID: {user_id}, Listing ID: {store_listing_id}" + ) + + # Verify the agent belongs to this user + agent = await prisma.models.AgentGraph.prisma().find_first( + where=prisma.types.AgentGraphWhereInput( + id=agent_id, version=agent_version, userId=user_id + ) + ) + + if not agent: + raise backend.server.v2.store.exceptions.AgentNotFoundError( + f"Agent not found for this user. User ID: {user_id}, Agent ID: {agent_id}, Version: {agent_version}" + ) + + # Get the latest version number + latest_version = listing.Versions[0] if listing.Versions else None + + next_version = (latest_version.version + 1) if latest_version else 1 + + # Create a new version for the existing listing + new_version = await prisma.models.StoreListingVersion.prisma().create( + data=prisma.types.StoreListingVersionCreateInput( + version=next_version, + agentGraphId=agent_id, + agentGraphVersion=agent_version, + name=name, + videoUrl=video_url, + imageUrls=image_urls, + description=description, + categories=categories, + subHeading=sub_heading, + submissionStatus=prisma.enums.SubmissionStatus.PENDING, + submittedAt=datetime.now(), + changesSummary=changes_summary, + storeListingId=store_listing_id, + ) + ) + + logger.debug( + f"Created new version for listing {store_listing_id} of agent {agent_id}" + ) + # Return submission details + return backend.server.v2.store.model.StoreSubmission( + agent_id=agent_id, + agent_version=agent_version, + name=name, + slug=listing.slug, + sub_heading=sub_heading, + description=description, + image_urls=image_urls, + date_submitted=datetime.now(), + status=prisma.enums.SubmissionStatus.PENDING, + runs=0, + rating=0.0, + store_listing_version_id=new_version.id, + changes_summary=changes_summary, + version=next_version, + ) + + except prisma.errors.PrismaError as e: + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to create new store version" + ) from e + + async def create_store_review( user_id: str, store_listing_version_id: str, score: int, comments: str | None = None, ) -> backend.server.v2.store.model.StoreReview: + """Create a review for a store listing as a user to detail their experience""" try: + data = prisma.types.StoreListingReviewUpsertInput( + update=prisma.types.StoreListingReviewUpdateInput( + score=score, + comments=comments, + ), + create=prisma.types.StoreListingReviewCreateInput( + reviewByUserId=user_id, + storeListingVersionId=store_listing_version_id, + score=score, + comments=comments, + ), + ) review = await prisma.models.StoreListingReview.prisma().upsert( where={ "storeListingVersionId_reviewByUserId": { @@ -540,18 +993,7 @@ async def create_store_review( "reviewByUserId": user_id, } }, - data={ - "create": { - "reviewByUserId": user_id, - "storeListingVersionId": store_listing_version_id, - "score": score, - "comments": comments, - }, - "update": { - "score": score, - "comments": comments, - }, - }, + data=data, ) return backend.server.v2.store.model.StoreReview( @@ -560,7 +1002,7 @@ async def create_store_review( ) except prisma.errors.PrismaError as e: - logger.error(f"Database error creating store review: {str(e)}") + logger.error(f"Database error creating store review: {e}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to create store review" ) from e @@ -568,34 +1010,16 @@ async def create_store_review( async def get_user_profile( user_id: str, -) -> backend.server.v2.store.model.ProfileDetails: +) -> backend.server.v2.store.model.ProfileDetails | None: logger.debug(f"Getting user profile for {user_id}") try: profile = await prisma.models.Profile.prisma().find_first( - where={"userId": user_id} # type: ignore + where={"userId": user_id} ) if not profile: - logger.warning(f"Profile not found for user {user_id}") - await prisma.models.Profile.prisma().create( - data=prisma.types.ProfileCreateInput( - userId=user_id, - name="No Profile Data", - username=f"{random.choice(['happy', 'clever', 'swift', 'bright', 'wise'])}-{random.choice(['fox', 'wolf', 'bear', 'eagle', 'owl'])}_{random.randint(1000,9999)}", - description="No Profile Data", - links=[], - avatarUrl="", - ) - ) - return backend.server.v2.store.model.ProfileDetails( - name="No Profile Data", - username="No Profile Data", - description="No Profile Data", - links=[], - avatar_url="", - ) - + return None return backend.server.v2.store.model.ProfileDetails( name=profile.name, username=profile.username, @@ -604,98 +1028,88 @@ async def get_user_profile( avatar_url=profile.avatarUrl, ) except Exception as e: - logger.error(f"Error getting user profile: {str(e)}") - return backend.server.v2.store.model.ProfileDetails( - name="No Profile Data", - username="No Profile Data", - description="No Profile Data", - links=[], - avatar_url="", - ) + logger.error(f"Error getting user profile: {e}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to get user profile" + ) from e -async def update_or_create_profile( +async def update_profile( user_id: str, profile: backend.server.v2.store.model.Profile ) -> backend.server.v2.store.model.CreatorDetails: """ - Update the store profile for a user. Creates a new profile if one doesn't exist. - Only allows updating if the user_id matches the owning user. - + Update the store profile for a user or create a new one if it doesn't exist. Args: user_id: ID of the authenticated user profile: Updated profile details - Returns: - CreatorDetails: The updated profile - + CreatorDetails: The updated or created profile details Raises: - HTTPException: If user is not authorized to update this profile + DatabaseError: If there's an issue updating or creating the profile """ - logger.debug(f"Updating profile for user {user_id}") - + logger.info(f"Updating profile for user {user_id} with data: {profile}") try: - # Check if profile exists for user + # Sanitize username to allow only letters, numbers, and hyphens + username = "".join( + c if c.isalpha() or c == "-" or c.isnumeric() else "" + for c in profile.username + ).lower() + # Check if profile exists for the given user_id existing_profile = await prisma.models.Profile.prisma().find_first( where={"userId": user_id} ) - - # If no profile exists, create a new one if not existing_profile: - logger.debug(f"Creating new profile for user {user_id}") - # Create new profile since one doesn't exist - new_profile = await prisma.models.Profile.prisma().create( - data={ - "userId": user_id, - "name": profile.name, - "username": profile.username, - "description": profile.description, - "links": profile.links, - "avatarUrl": profile.avatar_url, - } + raise backend.server.v2.store.exceptions.ProfileNotFoundError( + f"Profile not found for user {user_id}. This should not be possible." ) - return backend.server.v2.store.model.CreatorDetails( - name=new_profile.name, - username=new_profile.username, - description=new_profile.description, - links=new_profile.links, - avatar_url=new_profile.avatarUrl or "", - agent_rating=0.0, - agent_runs=0, - top_categories=[], + # Verify that the user is authorized to update this profile + if existing_profile.userId != user_id: + logger.error( + f"Unauthorized update attempt for profile {existing_profile.id} by user {user_id}" ) - else: - logger.debug(f"Updating existing profile for user {user_id}") - # Update the existing profile - updated_profile = await prisma.models.Profile.prisma().update( - where={"id": existing_profile.id}, - data=prisma.types.ProfileUpdateInput( - name=profile.name, - username=profile.username, - description=profile.description, - links=profile.links, - avatarUrl=profile.avatar_url, - ), + raise backend.server.v2.store.exceptions.DatabaseError( + f"Unauthorized update attempt for profile {existing_profile.id} by user {user_id}" ) - if updated_profile is None: - logger.error(f"Failed to update profile for user {user_id}") - raise backend.server.v2.store.exceptions.DatabaseError( - "Failed to update profile" - ) - return backend.server.v2.store.model.CreatorDetails( - name=updated_profile.name, - username=updated_profile.username, - description=updated_profile.description, - links=updated_profile.links, - avatar_url=updated_profile.avatarUrl or "", - agent_rating=0.0, - agent_runs=0, - top_categories=[], + logger.debug(f"Updating existing profile for user {user_id}") + # Prepare update data, only including non-None values + update_data = {} + if profile.name is not None: + update_data["name"] = profile.name + if profile.username is not None: + update_data["username"] = username + if profile.description is not None: + update_data["description"] = profile.description + if profile.links is not None: + update_data["links"] = profile.links + if profile.avatar_url is not None: + update_data["avatarUrl"] = profile.avatar_url + + # Update the existing profile + updated_profile = await prisma.models.Profile.prisma().update( + where={"id": existing_profile.id}, + data=prisma.types.ProfileUpdateInput(**update_data), + ) + if updated_profile is None: + logger.error(f"Failed to update profile for user {user_id}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to update profile" ) + return backend.server.v2.store.model.CreatorDetails( + name=updated_profile.name, + username=updated_profile.username, + description=updated_profile.description, + links=updated_profile.links, + avatar_url=updated_profile.avatarUrl or "", + agent_rating=0.0, + agent_runs=0, + top_categories=[], + ) + except prisma.errors.PrismaError as e: - logger.error(f"Database error updating profile: {str(e)}") + logger.error(f"Database error updating profile: {e}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to update profile" ) from e @@ -706,47 +1120,39 @@ async def get_my_agents( page: int = 1, page_size: int = 20, ) -> backend.server.v2.store.model.MyAgentsResponse: + """Get the agents for the authenticated user""" logger.debug(f"Getting my agents for user {user_id}, page={page}") try: - agents_with_max_version = await prisma.models.AgentGraph.prisma().find_many( - where=prisma.types.AgentGraphWhereInput( - userId=user_id, StoreListing={"none": {"isDeleted": False}} - ), - order=[{"version": "desc"}], - distinct=["id"], + search_filter: prisma.types.LibraryAgentWhereInput = { + "userId": user_id, + "AgentGraph": {"is": {"StoreListings": {"none": {"isDeleted": False}}}}, + "isArchived": False, + "isDeleted": False, + } + + library_agents = await prisma.models.LibraryAgent.prisma().find_many( + where=search_filter, + order=[{"updatedAt": "desc"}], skip=(page - 1) * page_size, take=page_size, + include={"AgentGraph": True}, ) - # store_listings = await prisma.models.StoreListing.prisma().find_many( - # where=prisma.types.StoreListingWhereInput( - # isDeleted=False, - # ), - # ) - - total = len( - await prisma.models.AgentGraph.prisma().find_many( - where=prisma.types.AgentGraphWhereInput( - userId=user_id, StoreListing={"none": {"isDeleted": False}} - ), - order=[{"version": "desc"}], - distinct=["id"], - ) - ) - + total = await prisma.models.LibraryAgent.prisma().count(where=search_filter) total_pages = (total + page_size - 1) // page_size - agents = agents_with_max_version - my_agents = [ backend.server.v2.store.model.MyAgent( - agent_id=agent.id, - agent_version=agent.version, - agent_name=agent.name or "", - last_edited=agent.updatedAt or agent.createdAt, + agent_id=graph.id, + agent_version=graph.version, + agent_name=graph.name or "", + last_edited=graph.updatedAt or graph.createdAt, + description=graph.description or "", + agent_image=library_agent.imageUrl, ) - for agent in agents + for library_agent in library_agents + if (graph := library_agent.AgentGraph) ] return backend.server.v2.store.model.MyAgentsResponse( @@ -759,7 +1165,516 @@ async def get_my_agents( ), ) except Exception as e: - logger.error(f"Error getting my agents: {str(e)}") + logger.error(f"Error getting my agents: {e}") raise backend.server.v2.store.exceptions.DatabaseError( "Failed to fetch my agents" ) from e + + +async def get_agent( + user_id: str | None, + store_listing_version_id: str, +) -> GraphModel: + """Get agent using the version ID and store listing version ID.""" + store_listing_version = ( + await prisma.models.StoreListingVersion.prisma().find_unique( + where={"id": store_listing_version_id} + ) + ) + + if not store_listing_version: + raise ValueError(f"Store listing version {store_listing_version_id} not found") + + graph = await get_graph( + user_id=user_id, + graph_id=store_listing_version.agentGraphId, + version=store_listing_version.agentGraphVersion, + for_export=True, + ) + if not graph: + raise ValueError( + f"Agent {store_listing_version.agentGraphId} v{store_listing_version.agentGraphVersion} not found" + ) + + return graph + + +##################################################### +################## ADMIN FUNCTIONS ################## +##################################################### + + +async def _get_missing_sub_store_listing( + graph: prisma.models.AgentGraph, +) -> list[prisma.models.AgentGraph]: + """ + Agent graph can have sub-graphs, and those sub-graphs also need to be store listed. + This method fetches the sub-graphs, and returns the ones not listed in the store. + """ + sub_graphs = await get_sub_graphs(graph) + if not sub_graphs: + return [] + + # Fetch all the sub-graphs that are listed, and return the ones missing. + store_listed_sub_graphs = { + (listing.agentGraphId, listing.agentGraphVersion) + for listing in await prisma.models.StoreListingVersion.prisma().find_many( + where={ + "OR": [ + { + "agentGraphId": sub_graph.id, + "agentGraphVersion": sub_graph.version, + } + for sub_graph in sub_graphs + ], + "submissionStatus": prisma.enums.SubmissionStatus.APPROVED, + "isDeleted": False, + } + ) + } + + return [ + sub_graph + for sub_graph in sub_graphs + if (sub_graph.id, sub_graph.version) not in store_listed_sub_graphs + ] + + +async def review_store_submission( + store_listing_version_id: str, + is_approved: bool, + external_comments: str, + internal_comments: str, + reviewer_id: str, +) -> backend.server.v2.store.model.StoreSubmission: + """Review a store listing submission as an admin.""" + try: + store_listing_version = ( + await prisma.models.StoreListingVersion.prisma().find_unique( + where={"id": store_listing_version_id}, + include={ + "StoreListing": True, + "AgentGraph": {"include": {**AGENT_GRAPH_INCLUDE, "User": True}}, + "Reviewer": True, + }, + ) + ) + + if not store_listing_version or not store_listing_version.StoreListing: + raise fastapi.HTTPException( + status_code=404, + detail=f"Store listing version {store_listing_version_id} not found", + ) + + # Check if we're rejecting an already approved agent + is_rejecting_approved = ( + not is_approved + and store_listing_version.submissionStatus + == prisma.enums.SubmissionStatus.APPROVED + ) + + # If approving, update the listing to indicate it has an approved version + if is_approved and store_listing_version.AgentGraph: + heading = f"Sub-graph of {store_listing_version.name}v{store_listing_version.agentGraphVersion}" + + sub_store_listing_versions = [ + prisma.types.StoreListingVersionCreateWithoutRelationsInput( + agentGraphId=sub_graph.id, + agentGraphVersion=sub_graph.version, + name=sub_graph.name or heading, + submissionStatus=prisma.enums.SubmissionStatus.APPROVED, + subHeading=heading, + description=f"{heading}: {sub_graph.description}", + changesSummary=f"This listing is added as a {heading} / #{store_listing_version.agentGraphId}.", + isAvailable=False, # Hide sub-graphs from the store by default. + submittedAt=datetime.now(tz=timezone.utc), + ) + for sub_graph in await _get_missing_sub_store_listing( + store_listing_version.AgentGraph + ) + ] + + await prisma.models.StoreListing.prisma().update( + where={"id": store_listing_version.StoreListing.id}, + data={ + "hasApprovedVersion": True, + "ActiveVersion": {"connect": {"id": store_listing_version_id}}, + "Versions": {"create": sub_store_listing_versions}, + }, + ) + + # If rejecting an approved agent, update the StoreListing accordingly + if is_rejecting_approved: + # Check if there are other approved versions + other_approved = ( + await prisma.models.StoreListingVersion.prisma().find_first( + where={ + "storeListingId": store_listing_version.StoreListing.id, + "id": {"not": store_listing_version_id}, + "submissionStatus": prisma.enums.SubmissionStatus.APPROVED, + } + ) + ) + + if not other_approved: + # No other approved versions, update hasApprovedVersion to False + await prisma.models.StoreListing.prisma().update( + where={"id": store_listing_version.StoreListing.id}, + data={ + "hasApprovedVersion": False, + "ActiveVersion": {"disconnect": True}, + }, + ) + else: + # Set the most recent other approved version as active + await prisma.models.StoreListing.prisma().update( + where={"id": store_listing_version.StoreListing.id}, + data={ + "ActiveVersion": {"connect": {"id": other_approved.id}}, + }, + ) + + submission_status = ( + prisma.enums.SubmissionStatus.APPROVED + if is_approved + else prisma.enums.SubmissionStatus.REJECTED + ) + + # Update the version with review information + update_data: prisma.types.StoreListingVersionUpdateInput = { + "submissionStatus": submission_status, + "reviewComments": external_comments, + "internalComments": internal_comments, + "Reviewer": {"connect": {"id": reviewer_id}}, + "StoreListing": {"connect": {"id": store_listing_version.StoreListing.id}}, + "reviewedAt": datetime.now(tz=timezone.utc), + } + + # Update the version + submission = await prisma.models.StoreListingVersion.prisma().update( + where={"id": store_listing_version_id}, + data=update_data, + include={"StoreListing": True}, + ) + + if not submission: + raise backend.server.v2.store.exceptions.DatabaseError( + f"Failed to update store listing version {store_listing_version_id}" + ) + + # Send email notification to the agent creator + if store_listing_version.AgentGraph and store_listing_version.AgentGraph.User: + agent_creator = store_listing_version.AgentGraph.User + reviewer = ( + store_listing_version.Reviewer + if store_listing_version.Reviewer + else None + ) + + try: + base_url = ( + settings.config.frontend_base_url + or settings.config.platform_base_url + ) + + if is_approved: + store_agent = ( + await prisma.models.StoreAgent.prisma().find_first_or_raise( + where={"storeListingVersionId": submission.id} + ) + ) + + # Send approval notification + notification_data = AgentApprovalData( + agent_name=submission.name, + agent_id=submission.agentGraphId, + agent_version=submission.agentGraphVersion, + reviewer_name=( + reviewer.name + if reviewer and reviewer.name + else DEFAULT_ADMIN_NAME + ), + reviewer_email=( + reviewer.email if reviewer else DEFAULT_ADMIN_EMAIL + ), + comments=external_comments, + reviewed_at=submission.reviewedAt + or datetime.now(tz=timezone.utc), + store_url=f"{base_url}/marketplace/agent/{store_agent.creator_username}/{store_agent.slug}", + ) + + notification_event = NotificationEventModel[AgentApprovalData]( + user_id=agent_creator.id, + type=prisma.enums.NotificationType.AGENT_APPROVED, + data=notification_data, + ) + else: + # Send rejection notification + notification_data = AgentRejectionData( + agent_name=submission.name, + agent_id=submission.agentGraphId, + agent_version=submission.agentGraphVersion, + reviewer_name=( + reviewer.name + if reviewer and reviewer.name + else DEFAULT_ADMIN_NAME + ), + reviewer_email=( + reviewer.email if reviewer else DEFAULT_ADMIN_EMAIL + ), + comments=external_comments, + reviewed_at=submission.reviewedAt + or datetime.now(tz=timezone.utc), + resubmit_url=f"{base_url}/build?flowID={submission.agentGraphId}", + ) + + notification_event = NotificationEventModel[AgentRejectionData]( + user_id=agent_creator.id, + type=prisma.enums.NotificationType.AGENT_REJECTED, + data=notification_data, + ) + + # Queue the notification for immediate sending + await queue_notification_async(notification_event) + logger.info( + f"Queued {'approval' if is_approved else 'rejection'} notification for user {agent_creator.id} and agent {submission.name}" + ) + + except Exception as e: + logger.error(f"Failed to send email notification for agent review: {e}") + # Don't fail the review process if email sending fails + pass + + # Convert to Pydantic model for consistency + return backend.server.v2.store.model.StoreSubmission( + agent_id=submission.agentGraphId, + agent_version=submission.agentGraphVersion, + name=submission.name, + sub_heading=submission.subHeading, + slug=( + submission.StoreListing.slug + if hasattr(submission, "storeListing") and submission.StoreListing + else "" + ), + description=submission.description, + image_urls=submission.imageUrls or [], + date_submitted=submission.submittedAt or submission.createdAt, + status=submission.submissionStatus, + runs=0, # Default values since we don't have this data here + rating=0.0, + store_listing_version_id=submission.id, + reviewer_id=submission.reviewerId, + review_comments=submission.reviewComments, + internal_comments=submission.internalComments, + reviewed_at=submission.reviewedAt, + changes_summary=submission.changesSummary, + ) + + except Exception as e: + logger.error(f"Could not create store submission review: {e}") + raise backend.server.v2.store.exceptions.DatabaseError( + "Failed to create store submission review" + ) from e + + +async def get_admin_listings_with_versions( + status: prisma.enums.SubmissionStatus | None = None, + search_query: str | None = None, + page: int = 1, + page_size: int = 20, +) -> backend.server.v2.store.model.StoreListingsWithVersionsResponse: + """ + Get store listings for admins with all their versions. + + Args: + status: Filter by submission status (PENDING, APPROVED, REJECTED) + search_query: Search by name, description, or user email + page: Page number for pagination + page_size: Number of items per page + + Returns: + StoreListingsWithVersionsResponse with listings and their versions + """ + logger.debug( + f"Getting admin store listings with status={status}, search={search_query}, page={page}" + ) + + try: + # Build the where clause for StoreListing + where_dict: prisma.types.StoreListingWhereInput = { + "isDeleted": False, + } + if status: + where_dict["Versions"] = {"some": {"submissionStatus": status}} + + sanitized_query = sanitize_query(search_query) + if sanitized_query: + # Find users with matching email + matching_users = await prisma.models.User.prisma().find_many( + where={"email": {"contains": sanitized_query, "mode": "insensitive"}}, + ) + + user_ids = [user.id for user in matching_users] + + # Set up OR conditions + where_dict["OR"] = [ + {"slug": {"contains": sanitized_query, "mode": "insensitive"}}, + { + "Versions": { + "some": { + "name": {"contains": sanitized_query, "mode": "insensitive"} + } + } + }, + { + "Versions": { + "some": { + "description": { + "contains": sanitized_query, + "mode": "insensitive", + } + } + } + }, + { + "Versions": { + "some": { + "subHeading": { + "contains": sanitized_query, + "mode": "insensitive", + } + } + } + }, + ] + + # Add user_id condition if any users matched + if user_ids: + where_dict["OR"].append({"owningUserId": {"in": user_ids}}) + + # Calculate pagination + skip = (page - 1) * page_size + + # Create proper Prisma types for the query + where = prisma.types.StoreListingWhereInput(**where_dict) + include = prisma.types.StoreListingInclude( + Versions=prisma.types.FindManyStoreListingVersionArgsFromStoreListing( + order_by=prisma.types._StoreListingVersion_version_OrderByInput( + version="desc" + ) + ), + OwningUser=True, + ) + + # Query listings with their versions + listings = await prisma.models.StoreListing.prisma().find_many( + where=where, + skip=skip, + take=page_size, + include=include, + order=[{"createdAt": "desc"}], + ) + + # Get total count for pagination + total = await prisma.models.StoreListing.prisma().count(where=where) + total_pages = (total + page_size - 1) // page_size + + # Convert to response models + listings_with_versions = [] + for listing in listings: + versions: list[backend.server.v2.store.model.StoreSubmission] = [] + # If we have versions, turn them into StoreSubmission models + for version in listing.Versions or []: + version_model = backend.server.v2.store.model.StoreSubmission( + agent_id=version.agentGraphId, + agent_version=version.agentGraphVersion, + name=version.name, + sub_heading=version.subHeading, + slug=listing.slug, + description=version.description, + image_urls=version.imageUrls or [], + date_submitted=version.submittedAt or version.createdAt, + status=version.submissionStatus, + runs=0, # Default values since we don't have this data here + rating=0.0, # Default values since we don't have this data here + store_listing_version_id=version.id, + reviewer_id=version.reviewerId, + review_comments=version.reviewComments, + internal_comments=version.internalComments, + reviewed_at=version.reviewedAt, + changes_summary=version.changesSummary, + version=version.version, + ) + versions.append(version_model) + + # Get the latest version (first in the sorted list) + latest_version = versions[0] if versions else None + + creator_email = listing.OwningUser.email if listing.OwningUser else None + + listing_with_versions = ( + backend.server.v2.store.model.StoreListingWithVersions( + listing_id=listing.id, + slug=listing.slug, + agent_id=listing.agentGraphId, + agent_version=listing.agentGraphVersion, + active_version_id=listing.activeVersionId, + has_approved_version=listing.hasApprovedVersion, + creator_email=creator_email, + latest_version=latest_version, + versions=versions, + ) + ) + + listings_with_versions.append(listing_with_versions) + + logger.debug(f"Found {len(listings_with_versions)} listings for admin") + return backend.server.v2.store.model.StoreListingsWithVersionsResponse( + listings=listings_with_versions, + pagination=backend.server.v2.store.model.Pagination( + current_page=page, + total_items=total, + total_pages=total_pages, + page_size=page_size, + ), + ) + except Exception as e: + logger.error(f"Error fetching admin store listings: {e}") + # Return empty response rather than exposing internal errors + return backend.server.v2.store.model.StoreListingsWithVersionsResponse( + listings=[], + pagination=backend.server.v2.store.model.Pagination( + current_page=page, + total_items=0, + total_pages=0, + page_size=page_size, + ), + ) + + +async def get_agent_as_admin( + user_id: str | None, + store_listing_version_id: str, +) -> GraphModel: + """Get agent using the version ID and store listing version ID.""" + store_listing_version = ( + await prisma.models.StoreListingVersion.prisma().find_unique( + where={"id": store_listing_version_id} + ) + ) + + if not store_listing_version: + raise ValueError(f"Store listing version {store_listing_version_id} not found") + + graph = await get_graph_as_admin( + user_id=user_id, + graph_id=store_listing_version.agentGraphId, + version=store_listing_version.agentGraphVersion, + for_export=True, + ) + if not graph: + raise ValueError( + f"Agent {store_listing_version.agentGraphId} v{store_listing_version.agentGraphVersion} not found" + ) + + return graph diff --git a/autogpt_platform/backend/backend/server/v2/store/db_test.py b/autogpt_platform/backend/backend/server/v2/store/db_test.py index 24a068017819..7ad4509c19d3 100644 --- a/autogpt_platform/backend/backend/server/v2/store/db_test.py +++ b/autogpt_platform/backend/backend/server/v2/store/db_test.py @@ -1,5 +1,6 @@ from datetime import datetime +import prisma.enums import prisma.errors import prisma.models import pytest @@ -83,21 +84,43 @@ async def test_get_store_agent_details(mocker): updated_at=datetime.now(), ) - # Mock prisma call + # Create a mock StoreListing result + mock_store_listing = mocker.MagicMock() + mock_store_listing.activeVersionId = "active-version-id" + mock_store_listing.hasApprovedVersion = True + + # Mock StoreAgent prisma call mock_store_agent = mocker.patch("prisma.models.StoreAgent.prisma") mock_store_agent.return_value.find_first = mocker.AsyncMock(return_value=mock_agent) + # Mock Profile prisma call + mock_profile = mocker.MagicMock() + mock_profile.userId = "user-id-123" + mock_profile_db = mocker.patch("prisma.models.Profile.prisma") + mock_profile_db.return_value.find_first = mocker.AsyncMock( + return_value=mock_profile + ) + + # Mock StoreListing prisma call - this is what was missing + mock_store_listing_db = mocker.patch("prisma.models.StoreListing.prisma") + mock_store_listing_db.return_value.find_first = mocker.AsyncMock( + return_value=mock_store_listing + ) + # Call function result = await db.get_store_agent_details("creator", "test-agent") # Verify results assert result.slug == "test-agent" assert result.agent_name == "Test Agent" + assert result.active_version_id == "active-version-id" + assert result.has_approved_version is True - # Verify mock called correctly + # Verify mocks called correctly mock_store_agent.return_value.find_first.assert_called_once_with( where={"creator_username": "creator", "slug": "test-agent"} ) + mock_store_listing_db.return_value.find_first.assert_called_once() @pytest.mark.asyncio @@ -146,7 +169,6 @@ async def test_create_store_submission(mocker): userId="user-id", createdAt=datetime.now(), isActive=True, - isTemplate=False, ) mock_listing = prisma.models.StoreListing( @@ -154,10 +176,31 @@ async def test_create_store_submission(mocker): createdAt=datetime.now(), updatedAt=datetime.now(), isDeleted=False, - isApproved=False, - agentId="agent-id", - agentVersion=1, + hasApprovedVersion=False, + slug="test-agent", + agentGraphId="agent-id", + agentGraphVersion=1, owningUserId="user-id", + Versions=[ + prisma.models.StoreListingVersion( + id="version-id", + agentGraphId="agent-id", + agentGraphVersion=1, + name="Test Agent", + description="Test description", + createdAt=datetime.now(), + updatedAt=datetime.now(), + subHeading="Test heading", + imageUrls=["image.jpg"], + categories=["test"], + isFeatured=False, + isDeleted=False, + version=1, + storeListingId="listing-id", + submissionStatus=prisma.enums.SubmissionStatus.PENDING, + isAvailable=True, + ) + ], ) # Mock prisma calls @@ -181,6 +224,7 @@ async def test_create_store_submission(mocker): # Verify results assert result.name == "Test Agent" assert result.description == "Test description" + assert result.store_listing_version_id == "version-id" # Verify mocks called correctly mock_agent_graph.return_value.find_first.assert_called_once() @@ -195,6 +239,7 @@ async def test_update_profile(mocker): id="profile-id", name="Test Creator", username="creator", + userId="user-id", description="Test description", links=["link1"], avatarUrl="avatar.jpg", @@ -221,7 +266,7 @@ async def test_update_profile(mocker): ) # Call function - result = await db.update_or_create_profile("user-id", profile) + result = await db.update_profile("user-id", profile) # Verify results assert result.username == "creator" @@ -237,7 +282,7 @@ async def test_get_user_profile(mocker): # Mock data mock_profile = prisma.models.Profile( id="profile-id", - name="No Profile Data", + name="Test User", username="testuser", description="Test description", links=["link1", "link2"], @@ -245,20 +290,22 @@ async def test_get_user_profile(mocker): isFeatured=False, createdAt=datetime.now(), updatedAt=datetime.now(), + userId="user-id", ) # Mock prisma calls mock_profile_db = mocker.patch("prisma.models.Profile.prisma") - mock_profile_db.return_value.find_unique = mocker.AsyncMock( + mock_profile_db.return_value.find_first = mocker.AsyncMock( return_value=mock_profile ) # Call function result = await db.get_user_profile("user-id") + assert result is not None # Verify results - assert result.name == "No Profile Data" - assert result.username == "No Profile Data" - assert result.description == "No Profile Data" - assert result.links == [] - assert result.avatar_url == "" + assert result.name == "Test User" + assert result.username == "testuser" + assert result.description == "Test description" + assert result.links == ["link1", "link2"] + assert result.avatar_url == "avatar.jpg" diff --git a/autogpt_platform/backend/backend/server/v2/store/exceptions.py b/autogpt_platform/backend/backend/server/v2/store/exceptions.py index f63264be4d94..0b1020db6064 100644 --- a/autogpt_platform/backend/backend/server/v2/store/exceptions.py +++ b/autogpt_platform/backend/backend/server/v2/store/exceptions.py @@ -34,6 +34,20 @@ class StorageUploadError(MediaUploadError): pass +class VirusDetectedError(MediaUploadError): + """Raised when a virus is detected in uploaded file""" + + def __init__(self, threat_name: str, message: str | None = None): + self.threat_name = threat_name + super().__init__(message or f"Virus detected: {threat_name}") + + +class VirusScanError(MediaUploadError): + """Raised when virus scanning fails""" + + pass + + class StoreError(Exception): """Base exception for store-related errors""" @@ -70,7 +84,25 @@ class ProfileNotFoundError(StoreError): pass +class ListingNotFoundError(StoreError): + """Raised when a store listing is not found""" + + pass + + class SubmissionNotFoundError(StoreError): """Raised when a submission is not found""" pass + + +class InvalidOperationError(StoreError): + """Raised when an operation is not valid for the current state""" + + pass + + +class UnauthorizedError(StoreError): + """Raised when a user is not authorized to perform an action""" + + pass diff --git a/autogpt_platform/backend/backend/server/v2/store/image_gen.py b/autogpt_platform/backend/backend/server/v2/store/image_gen.py new file mode 100644 index 000000000000..87b7b601dfeb --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/store/image_gen.py @@ -0,0 +1,168 @@ +import io +import logging +from enum import Enum + +from prisma.models import AgentGraph +from replicate.client import Client as ReplicateClient +from replicate.exceptions import ReplicateError +from replicate.helpers import FileOutput + +from backend.blocks.ideogram import ( + AspectRatio, + ColorPalettePreset, + IdeogramModelBlock, + IdeogramModelName, + MagicPromptOption, + StyleType, + UpscaleOption, +) +from backend.data.graph import BaseGraph +from backend.data.model import CredentialsMetaInput, ProviderName +from backend.integrations.credentials_store import ideogram_credentials +from backend.util.request import Requests +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) +settings = Settings() + + +class ImageSize(str, Enum): + LANDSCAPE = "1024x768" + + +class ImageStyle(str, Enum): + DIGITAL_ART = "digital art" + + +async def generate_agent_image(agent: BaseGraph | AgentGraph) -> io.BytesIO: + if settings.config.use_agent_image_generation_v2: + return await generate_agent_image_v2(graph=agent) + else: + return await generate_agent_image_v1(agent=agent) + + +async def generate_agent_image_v2(graph: BaseGraph | AgentGraph) -> io.BytesIO: + """ + Generate an image for an agent using Ideogram model. + Returns: + str: The URL of the generated image + """ + if not ideogram_credentials.api_key: + raise ValueError("Missing Ideogram API key") + + name = graph.name + description = f"{name} ({graph.description})" if graph.description else name + + prompt = ( + f"Create a visually striking retro-futuristic vector pop art illustration prominently featuring " + f'"{name}" in bold typography. The image clearly and literally depicts a {description}, ' + f"along with recognizable objects directly associated with the primary function of a {name}. " + f"Ensure the imagery is concrete, intuitive, and immediately understandable, clearly conveying the " + f"purpose of a {name}. Maintain vibrant, limited-palette colors, sharp vector lines, geometric " + f"shapes, flat illustration techniques, and solid colors without gradients or shading. Preserve a " + f"retro-futuristic aesthetic influenced by mid-century futurism and 1960s psychedelia, " + f"prioritizing clear visual storytelling and thematic clarity above all else." + ) + + custom_colors = [ + "#000030", + "#1C0C47", + "#9900FF", + "#4285F4", + "#FFFFFF", + ] + + # Run the Ideogram model block with the specified parameters + url = await IdeogramModelBlock().run_once( + IdeogramModelBlock.Input( + credentials=CredentialsMetaInput( + id=ideogram_credentials.id, + provider=ProviderName.IDEOGRAM, + title=ideogram_credentials.title, + type=ideogram_credentials.type, + ), + prompt=prompt, + ideogram_model_name=IdeogramModelName.V3, + aspect_ratio=AspectRatio.ASPECT_16_9, + magic_prompt_option=MagicPromptOption.OFF, + style_type=StyleType.AUTO, + upscale=UpscaleOption.NO_UPSCALE, + color_palette_name=ColorPalettePreset.NONE, + custom_color_palette=custom_colors, + seed=None, + negative_prompt=None, + ), + "result", + credentials=ideogram_credentials, + ) + response = await Requests().get(url) + return io.BytesIO(response.content) + + +async def generate_agent_image_v1(agent: BaseGraph | AgentGraph) -> io.BytesIO: + """ + Generate an image for an agent using Flux model via Replicate API. + + Args: + agent (Graph): The agent to generate an image for + + Returns: + io.BytesIO: The generated image as bytes + """ + try: + if not settings.secrets.replicate_api_key: + raise ValueError("Missing Replicate API key in settings") + + # Construct prompt from agent details + prompt = f"Create a visually engaging app store thumbnail for the AI agent that highlights what it does in a clear and captivating way:\n- **Name**: {agent.name}\n- **Description**: {agent.description}\nFocus on showcasing its core functionality with an appealing design." + + # Set up Replicate client + client = ReplicateClient(api_token=settings.secrets.replicate_api_key) + + # Model parameters + input_data = { + "prompt": prompt, + "width": 1024, + "height": 768, + "aspect_ratio": "4:3", + "output_format": "jpg", + "output_quality": 90, + "num_inference_steps": 30, + "guidance": 3.5, + "negative_prompt": "blurry, low quality, distorted, deformed", + "disable_safety_checker": True, + } + + try: + # Run model + output = client.run("black-forest-labs/flux-1.1-pro", input=input_data) + + # Depending on the model output, extract the image URL or bytes + # If the output is a list of FileOutput or URLs + if isinstance(output, list) and output: + if isinstance(output[0], FileOutput): + image_bytes = output[0].read() + else: + # If it's a URL string, fetch the image bytes + result_url = output[0] + response = await Requests().get(result_url) + image_bytes = response.content + elif isinstance(output, FileOutput): + image_bytes = output.read() + elif isinstance(output, str): + # Output is a URL + response = await Requests().get(output) + image_bytes = response.content + else: + raise RuntimeError("Unexpected output format from the model.") + + return io.BytesIO(image_bytes) + + except ReplicateError as e: + if e.status == 401: + raise RuntimeError("Invalid Replicate API token") from e + raise RuntimeError(f"Replicate API error: {str(e)}") from e + + except Exception as e: + logger.exception("Failed to generate agent image") + raise RuntimeError(f"Image generation failed: {str(e)}") diff --git a/autogpt_platform/backend/backend/server/v2/store/media.py b/autogpt_platform/backend/backend/server/v2/store/media.py index 7ced3a842b9a..88542dd2c8f2 100644 --- a/autogpt_platform/backend/backend/server/v2/store/media.py +++ b/autogpt_platform/backend/backend/server/v2/store/media.py @@ -3,10 +3,12 @@ import uuid import fastapi -from google.cloud import storage +from gcloud.aio import storage as async_storage import backend.server.v2.store.exceptions +from backend.util.exceptions import MissingConfigError from backend.util.settings import Settings +from backend.util.virus_scanner import scan_content_safe logger = logging.getLogger(__name__) @@ -15,8 +17,51 @@ MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB -async def upload_media(user_id: str, file: fastapi.UploadFile) -> str: +async def check_media_exists(user_id: str, filename: str) -> str | None: + """ + Check if a media file exists in storage for the given user. + Tries both images and videos directories. + Args: + user_id (str): ID of the user who uploaded the file + filename (str): Name of the file to check + + Returns: + str | None: URL of the blob if it exists, None otherwise + """ + settings = Settings() + if not settings.config.media_gcs_bucket_name: + raise MissingConfigError("GCS media bucket is not configured") + + async with async_storage.Storage() as async_client: + bucket_name = settings.config.media_gcs_bucket_name + + # Check images + image_path = f"users/{user_id}/images/{filename}" + try: + await async_client.download_metadata(bucket_name, image_path) + # If we get here, the file exists - construct public URL + return f"https://storage.googleapis.com/{bucket_name}/{image_path}" + except Exception: + # File doesn't exist, continue to check videos + pass + + # Check videos + video_path = f"users/{user_id}/videos/{filename}" + try: + await async_client.download_metadata(bucket_name, video_path) + # If we get here, the file exists - construct public URL + return f"https://storage.googleapis.com/{bucket_name}/{video_path}" + except Exception: + # File doesn't exist + pass + + return None + + +async def upload_media( + user_id: str, file: fastapi.UploadFile, use_file_name: bool = False +) -> str: # Get file content for deeper validation try: content = await file.read(1024) # Read first 1KB for validation @@ -30,7 +75,7 @@ async def upload_media(user_id: str, file: fastapi.UploadFile) -> str: # Validate file signature/magic bytes if file.content_type in ALLOWED_IMAGE_TYPES: # Check image file signatures - if content.startswith(b"\xFF\xD8\xFF"): # JPEG + if content.startswith(b"\xff\xd8\xff"): # JPEG if file.content_type != "image/jpeg": raise backend.server.v2.store.exceptions.InvalidFileTypeError( "File signature does not match content type" @@ -84,6 +129,9 @@ async def upload_media(user_id: str, file: fastapi.UploadFile) -> str: try: # Validate file type content_type = file.content_type + if content_type is None: + content_type = "image/jpeg" + if ( content_type not in ALLOWED_IMAGE_TYPES and content_type not in ALLOWED_VIDEO_TYPES @@ -119,25 +167,34 @@ async def upload_media(user_id: str, file: fastapi.UploadFile) -> str: # Generate unique filename filename = file.filename or "" file_ext = os.path.splitext(filename)[1].lower() - unique_filename = f"{uuid.uuid4()}{file_ext}" + if use_file_name: + unique_filename = filename + else: + unique_filename = f"{uuid.uuid4()}{file_ext}" # Construct storage path media_type = "images" if content_type in ALLOWED_IMAGE_TYPES else "videos" storage_path = f"users/{user_id}/{media_type}/{unique_filename}" try: - storage_client = storage.Client() - bucket = storage_client.bucket(settings.config.media_gcs_bucket_name) - blob = bucket.blob(storage_path) - blob.content_type = content_type + async with async_storage.Storage() as async_client: + bucket_name = settings.config.media_gcs_bucket_name - file_bytes = await file.read() - blob.upload_from_string(file_bytes, content_type=content_type) + file_bytes = await file.read() + await scan_content_safe(file_bytes, filename=unique_filename) - public_url = blob.public_url + # Upload using pure async client + await async_client.upload( + bucket_name, storage_path, file_bytes, content_type=content_type + ) + + # Construct public URL + public_url = ( + f"https://storage.googleapis.com/{bucket_name}/{storage_path}" + ) - logger.info(f"Successfully uploaded file to: {storage_path}") - return public_url + logger.info(f"Successfully uploaded file to: {storage_path}") + return public_url except Exception as e: logger.error(f"GCS storage error: {str(e)}") diff --git a/autogpt_platform/backend/backend/server/v2/store/media_test.py b/autogpt_platform/backend/backend/server/v2/store/media_test.py index bd5222e4f749..3722d2fdc3db 100644 --- a/autogpt_platform/backend/backend/server/v2/store/media_test.py +++ b/autogpt_platform/backend/backend/server/v2/store/media_test.py @@ -1,5 +1,6 @@ import io import unittest.mock +from unittest.mock import AsyncMock import fastapi import pytest @@ -21,15 +22,23 @@ def mock_settings(monkeypatch): @pytest.fixture def mock_storage_client(mocker): - mock_client = unittest.mock.MagicMock() - mock_bucket = unittest.mock.MagicMock() - mock_blob = unittest.mock.MagicMock() + # Mock the async gcloud.aio.storage.Storage client + mock_client = AsyncMock() + mock_client.upload = AsyncMock() - mock_client.bucket.return_value = mock_bucket - mock_bucket.blob.return_value = mock_blob - mock_blob.public_url = "http://test-url/media/laptop.jpeg" + # Mock context manager methods + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=None) - mocker.patch("google.cloud.storage.Client", return_value=mock_client) + # Mock the constructor to return our mock client + mocker.patch( + "backend.server.v2.store.media.async_storage.Storage", return_value=mock_client + ) + + # Mock virus scanner to avoid actual scanning + mocker.patch( + "backend.server.v2.store.media.scan_content_safe", new_callable=AsyncMock + ) return mock_client @@ -46,10 +55,11 @@ async def test_upload_media_success(mock_settings, mock_storage_client): result = await backend.server.v2.store.media.upload_media("test-user", test_file) - assert result == "http://test-url/media/laptop.jpeg" - mock_bucket = mock_storage_client.bucket.return_value - mock_blob = mock_bucket.blob.return_value - mock_blob.upload_from_string.assert_called_once() + assert result.startswith( + "https://storage.googleapis.com/test-bucket/users/test-user/images/" + ) + assert result.endswith(".jpeg") + mock_storage_client.upload.assert_called_once() async def test_upload_media_invalid_type(mock_settings, mock_storage_client): @@ -62,9 +72,7 @@ async def test_upload_media_invalid_type(mock_settings, mock_storage_client): with pytest.raises(backend.server.v2.store.exceptions.InvalidFileTypeError): await backend.server.v2.store.media.upload_media("test-user", test_file) - mock_bucket = mock_storage_client.bucket.return_value - mock_blob = mock_bucket.blob.return_value - mock_blob.upload_from_string.assert_not_called() + mock_storage_client.upload.assert_not_called() async def test_upload_media_missing_credentials(monkeypatch): @@ -92,10 +100,11 @@ async def test_upload_media_video_type(mock_settings, mock_storage_client): result = await backend.server.v2.store.media.upload_media("test-user", test_file) - assert result == "http://test-url/media/laptop.jpeg" - mock_bucket = mock_storage_client.bucket.return_value - mock_blob = mock_bucket.blob.return_value - mock_blob.upload_from_string.assert_called_once() + assert result.startswith( + "https://storage.googleapis.com/test-bucket/users/test-user/videos/" + ) + assert result.endswith(".mp4") + mock_storage_client.upload.assert_called_once() async def test_upload_media_file_too_large(mock_settings, mock_storage_client): @@ -132,7 +141,10 @@ async def test_upload_media_png_success(mock_settings, mock_storage_client): ) result = await backend.server.v2.store.media.upload_media("test-user", test_file) - assert result == "http://test-url/media/laptop.jpeg" + assert result.startswith( + "https://storage.googleapis.com/test-bucket/users/test-user/images/" + ) + assert result.endswith(".png") async def test_upload_media_gif_success(mock_settings, mock_storage_client): @@ -143,7 +155,10 @@ async def test_upload_media_gif_success(mock_settings, mock_storage_client): ) result = await backend.server.v2.store.media.upload_media("test-user", test_file) - assert result == "http://test-url/media/laptop.jpeg" + assert result.startswith( + "https://storage.googleapis.com/test-bucket/users/test-user/images/" + ) + assert result.endswith(".gif") async def test_upload_media_webp_success(mock_settings, mock_storage_client): @@ -154,7 +169,10 @@ async def test_upload_media_webp_success(mock_settings, mock_storage_client): ) result = await backend.server.v2.store.media.upload_media("test-user", test_file) - assert result == "http://test-url/media/laptop.jpeg" + assert result.startswith( + "https://storage.googleapis.com/test-bucket/users/test-user/images/" + ) + assert result.endswith(".webp") async def test_upload_media_webm_success(mock_settings, mock_storage_client): @@ -165,7 +183,10 @@ async def test_upload_media_webm_success(mock_settings, mock_storage_client): ) result = await backend.server.v2.store.media.upload_media("test-user", test_file) - assert result == "http://test-url/media/laptop.jpeg" + assert result.startswith( + "https://storage.googleapis.com/test-bucket/users/test-user/videos/" + ) + assert result.endswith(".webm") async def test_upload_media_mismatched_signature(mock_settings, mock_storage_client): diff --git a/autogpt_platform/backend/backend/server/v2/store/model.py b/autogpt_platform/backend/backend/server/v2/store/model.py index e7a4e7c411c0..a9afaa65964c 100644 --- a/autogpt_platform/backend/backend/server/v2/store/model.py +++ b/autogpt_platform/backend/backend/server/v2/store/model.py @@ -4,26 +4,15 @@ import prisma.enums import pydantic - -class Pagination(pydantic.BaseModel): - total_items: int = pydantic.Field( - description="Total number of items.", examples=[42] - ) - total_pages: int = pydantic.Field( - description="Total number of pages.", examples=[97] - ) - current_page: int = pydantic.Field( - description="Current_page page number.", examples=[1] - ) - page_size: int = pydantic.Field( - description="Number of items per page.", examples=[25] - ) +from backend.util.models import Pagination class MyAgent(pydantic.BaseModel): agent_id: str agent_version: int agent_name: str + agent_image: str | None = None + description: str last_edited: datetime.datetime @@ -65,6 +54,9 @@ class StoreAgentDetails(pydantic.BaseModel): versions: list[str] last_updated: datetime.datetime + active_version_id: str | None = None + has_approved_version: bool = False + class Creator(pydantic.BaseModel): name: str @@ -114,6 +106,18 @@ class StoreSubmission(pydantic.BaseModel): status: prisma.enums.SubmissionStatus runs: int rating: float + store_listing_version_id: str | None = None + version: int | None = None # Actual version number from the database + + reviewer_id: str | None = None + review_comments: str | None = None # External comments visible to creator + internal_comments: str | None = None # Private notes for admin use only + reviewed_at: datetime.datetime | None = None + changes_summary: str | None = None + + # Additional fields for editing + video_url: str | None = None + categories: list[str] = [] class StoreSubmissionsResponse(pydantic.BaseModel): @@ -121,6 +125,27 @@ class StoreSubmissionsResponse(pydantic.BaseModel): pagination: Pagination +class StoreListingWithVersions(pydantic.BaseModel): + """A store listing with its version history""" + + listing_id: str + slug: str + agent_id: str + agent_version: int + active_version_id: str | None = None + has_approved_version: bool = False + creator_email: str | None = None + latest_version: StoreSubmission | None = None + versions: list[StoreSubmission] = [] + + +class StoreListingsWithVersionsResponse(pydantic.BaseModel): + """Response model for listings with version history""" + + listings: list[StoreListingWithVersions] + pagination: Pagination + + class StoreSubmissionRequest(pydantic.BaseModel): agent_id: str agent_version: int @@ -131,6 +156,17 @@ class StoreSubmissionRequest(pydantic.BaseModel): image_urls: list[str] = [] description: str = "" categories: list[str] = [] + changes_summary: str | None = None + + +class StoreSubmissionEditRequest(pydantic.BaseModel): + name: str + sub_heading: str + video_url: str | None = None + image_urls: list[str] = [] + description: str = "" + categories: list[str] = [] + changes_summary: str | None = None class ProfileDetails(pydantic.BaseModel): @@ -150,3 +186,10 @@ class StoreReviewCreate(pydantic.BaseModel): store_listing_version_id: str score: int comments: str | None = None + + +class ReviewSubmissionRequest(pydantic.BaseModel): + store_listing_version_id: str + is_approved: bool + comments: str # External comments visible to creator + internal_comments: str | None = None # Private admin notes diff --git a/autogpt_platform/backend/backend/server/v2/store/routes.py b/autogpt_platform/backend/backend/server/v2/store/routes.py index 3ecfce29c9ca..dfe14b3ef044 100644 --- a/autogpt_platform/backend/backend/server/v2/store/routes.py +++ b/autogpt_platform/backend/backend/server/v2/store/routes.py @@ -1,14 +1,22 @@ import logging +import tempfile import typing +import urllib.parse import autogpt_libs.auth.depends import autogpt_libs.auth.middleware import fastapi import fastapi.responses +from autogpt_libs.auth.depends import auth_middleware, get_user_id +import backend.data.block +import backend.data.graph import backend.server.v2.store.db +import backend.server.v2.store.exceptions +import backend.server.v2.store.image_gen import backend.server.v2.store.media import backend.server.v2.store.model +import backend.util.json logger = logging.getLogger(__name__) @@ -20,34 +28,52 @@ ############################################## -@router.get("/profile", tags=["store", "private"]) +@router.get( + "/profile", + summary="Get user profile", + tags=["store", "private"], + response_model=backend.server.v2.store.model.ProfileDetails, +) async def get_profile( user_id: typing.Annotated[ str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) - ] -) -> backend.server.v2.store.model.ProfileDetails: + ], +): """ Get the profile details for the authenticated user. """ try: profile = await backend.server.v2.store.db.get_user_profile(user_id) + if profile is None: + return fastapi.responses.JSONResponse( + status_code=404, + content={"detail": "Profile not found"}, + ) return profile - except Exception: - logger.exception("Exception occurred whilst getting user profile") - raise + except Exception as e: + logger.exception("Failed to fetch user profile for %s: %s", user_id, e) + return fastapi.responses.JSONResponse( + status_code=500, + content={ + "detail": "Failed to retrieve user profile", + "hint": "Check database connection.", + }, + ) @router.post( "/profile", + summary="Update user profile", tags=["store", "private"], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], + response_model=backend.server.v2.store.model.CreatorDetails, ) async def update_or_create_profile( profile: backend.server.v2.store.model.Profile, user_id: typing.Annotated[ str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) ], -) -> backend.server.v2.store.model.CreatorDetails: +): """ Update the store profile for the authenticated user. @@ -62,13 +88,19 @@ async def update_or_create_profile( HTTPException: If there is an error updating the profile """ try: - updated_profile = await backend.server.v2.store.db.update_or_create_profile( + updated_profile = await backend.server.v2.store.db.update_profile( user_id=user_id, profile=profile ) return updated_profile - except Exception: - logger.exception("Exception occurred whilst updating profile") - raise + except Exception as e: + logger.exception("Failed to update profile for user %s: %s", user_id, e) + return fastapi.responses.JSONResponse( + status_code=500, + content={ + "detail": "Failed to update user profile", + "hint": "Validate request data.", + }, + ) ############################################## @@ -76,7 +108,12 @@ async def update_or_create_profile( ############################################## -@router.get("/agents", tags=["store", "public"]) +@router.get( + "/agents", + summary="List store agents", + tags=["store", "public"], + response_model=backend.server.v2.store.model.StoreAgentsResponse, +) async def get_agents( featured: bool = False, creator: str | None = None, @@ -85,7 +122,7 @@ async def get_agents( category: str | None = None, page: int = 1, page_size: int = 20, -) -> backend.server.v2.store.model.StoreAgentsResponse: +): """ Get a paginated list of agents from the store with optional filtering and sorting. @@ -125,7 +162,7 @@ async def get_agents( try: agents = await backend.server.v2.store.db.get_store_agents( featured=featured, - creator=creator, + creators=[creator] if creator else None, sorted_by=sorted_by, search_query=search_query, category=category, @@ -133,34 +170,104 @@ async def get_agents( page_size=page_size, ) return agents - except Exception: - logger.exception("Exception occured whilst getting store agents") - raise + except Exception as e: + logger.exception("Failed to retrieve store agents: %s", e) + return fastapi.responses.JSONResponse( + status_code=500, + content={ + "detail": "Failed to retrieve store agents", + "hint": "Check database or search parameters.", + }, + ) -@router.get("/agents/{username}/{agent_name}", tags=["store", "public"]) -async def get_agent( - username: str, agent_name: str -) -> backend.server.v2.store.model.StoreAgentDetails: +@router.get( + "/agents/{username}/{agent_name}", + summary="Get specific agent", + tags=["store", "public"], + response_model=backend.server.v2.store.model.StoreAgentDetails, +) +async def get_agent(username: str, agent_name: str): """ This is only used on the AgentDetails Page It returns the store listing agents details. """ try: + username = urllib.parse.unquote(username).lower() + # URL decode the agent name since it comes from the URL path + agent_name = urllib.parse.unquote(agent_name).lower() agent = await backend.server.v2.store.db.get_store_agent_details( username=username, agent_name=agent_name ) return agent except Exception: logger.exception("Exception occurred whilst getting store agent details") - raise + return fastapi.responses.JSONResponse( + status_code=500, + content={ + "detail": "An error occurred while retrieving the store agent details" + }, + ) + + +@router.get( + "/graph/{store_listing_version_id}", + summary="Get agent graph", + tags=["store"], +) +async def get_graph_meta_by_store_listing_version_id( + store_listing_version_id: str, + _: typing.Annotated[str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)], +): + """ + Get Agent Graph from Store Listing Version ID. + """ + try: + graph = await backend.server.v2.store.db.get_available_graph( + store_listing_version_id + ) + return graph + except Exception: + logger.exception("Exception occurred whilst getting agent graph") + return fastapi.responses.JSONResponse( + status_code=500, + content={"detail": "An error occurred while retrieving the agent graph"}, + ) + + +@router.get( + "/agents/{store_listing_version_id}", + summary="Get agent by version", + tags=["store"], + response_model=backend.server.v2.store.model.StoreAgentDetails, +) +async def get_store_agent( + store_listing_version_id: str, + _: typing.Annotated[str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id)], +): + """ + Get Store Agent Details from Store Listing Version ID. + """ + try: + agent = await backend.server.v2.store.db.get_store_agent_by_version_id( + store_listing_version_id + ) + return agent + except Exception: + logger.exception("Exception occurred whilst getting store agent") + return fastapi.responses.JSONResponse( + status_code=500, + content={"detail": "An error occurred while retrieving the store agent"}, + ) @router.post( "/agents/{username}/{agent_name}/review", + summary="Create agent review", tags=["store"], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], + response_model=backend.server.v2.store.model.StoreReview, ) async def create_review( username: str, @@ -169,7 +276,7 @@ async def create_review( user_id: typing.Annotated[ str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) ], -) -> backend.server.v2.store.model.StoreReview: +): """ Create a review for a store agent. @@ -183,6 +290,8 @@ async def create_review( The created review """ try: + username = urllib.parse.unquote(username).lower() + agent_name = urllib.parse.unquote(agent_name) # Create the review created_review = await backend.server.v2.store.db.create_store_review( user_id=user_id, @@ -194,7 +303,10 @@ async def create_review( return created_review except Exception: logger.exception("Exception occurred whilst creating store review") - raise + return fastapi.responses.JSONResponse( + status_code=500, + content={"detail": "An error occurred while creating the store review"}, + ) ############################################## @@ -202,14 +314,19 @@ async def create_review( ############################################## -@router.get("/creators", tags=["store", "public"]) +@router.get( + "/creators", + summary="List store creators", + tags=["store", "public"], + response_model=backend.server.v2.store.model.CreatorsResponse, +) async def get_creators( featured: bool = False, search_query: str | None = None, sorted_by: str | None = None, page: int = 1, page_size: int = 20, -) -> backend.server.v2.store.model.CreatorsResponse: +): """ This is needed for: - Home Page Featured Creators @@ -243,23 +360,39 @@ async def get_creators( return creators except Exception: logger.exception("Exception occurred whilst getting store creators") - raise + return fastapi.responses.JSONResponse( + status_code=500, + content={"detail": "An error occurred while retrieving the store creators"}, + ) -@router.get("/creator/{username}", tags=["store", "public"]) -async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDetails: +@router.get( + "/creator/{username}", + summary="Get creator details", + tags=["store", "public"], + response_model=backend.server.v2.store.model.CreatorDetails, +) +async def get_creator( + username: str, +): """ Get the details of a creator - Creator Details Page """ try: + username = urllib.parse.unquote(username).lower() creator = await backend.server.v2.store.db.get_store_creator_details( - username=username + username=username.lower() ) return creator except Exception: logger.exception("Exception occurred whilst getting creator details") - raise + return fastapi.responses.JSONResponse( + status_code=500, + content={ + "detail": "An error occurred while retrieving the creator details" + }, + ) ############################################ @@ -267,33 +400,44 @@ async def get_creator(username: str) -> backend.server.v2.store.model.CreatorDet ############################################ @router.get( "/myagents", + summary="Get my agents", tags=["store", "private"], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], + response_model=backend.server.v2.store.model.MyAgentsResponse, ) async def get_my_agents( user_id: typing.Annotated[ str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) - ] -) -> backend.server.v2.store.model.MyAgentsResponse: + ], + page: typing.Annotated[int, fastapi.Query(ge=1)] = 1, + page_size: typing.Annotated[int, fastapi.Query(ge=1)] = 20, +): try: - agents = await backend.server.v2.store.db.get_my_agents(user_id) + agents = await backend.server.v2.store.db.get_my_agents( + user_id, page=page, page_size=page_size + ) return agents except Exception: logger.exception("Exception occurred whilst getting my agents") - raise + return fastapi.responses.JSONResponse( + status_code=500, + content={"detail": "An error occurred while retrieving the my agents"}, + ) @router.delete( "/submissions/{submission_id}", + summary="Delete store submission", tags=["store", "private"], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], + response_model=bool, ) async def delete_submission( user_id: typing.Annotated[ str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) ], submission_id: str, -) -> bool: +): """ Delete a store listing submission. @@ -312,13 +456,18 @@ async def delete_submission( return result except Exception: logger.exception("Exception occurred whilst deleting store submission") - raise + return fastapi.responses.JSONResponse( + status_code=500, + content={"detail": "An error occurred while deleting the store submission"}, + ) @router.get( "/submissions", + summary="List my submissions", tags=["store", "private"], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], + response_model=backend.server.v2.store.model.StoreSubmissionsResponse, ) async def get_submissions( user_id: typing.Annotated[ @@ -326,7 +475,7 @@ async def get_submissions( ], page: int = 1, page_size: int = 20, -) -> backend.server.v2.store.model.StoreSubmissionsResponse: +): """ Get a paginated list of store submissions for the authenticated user. @@ -359,20 +508,27 @@ async def get_submissions( return listings except Exception: logger.exception("Exception occurred whilst getting store submissions") - raise + return fastapi.responses.JSONResponse( + status_code=500, + content={ + "detail": "An error occurred while retrieving the store submissions" + }, + ) @router.post( "/submissions", + summary="Create store submission", tags=["store", "private"], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], + response_model=backend.server.v2.store.model.StoreSubmission, ) async def create_submission( submission_request: backend.server.v2.store.model.StoreSubmissionRequest, user_id: typing.Annotated[ str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) ], -) -> backend.server.v2.store.model.StoreSubmission: +): """ Create a new store listing submission. @@ -387,7 +543,7 @@ async def create_submission( HTTPException: If there is an error creating the submission """ try: - submission = await backend.server.v2.store.db.create_store_submission( + return await backend.server.v2.store.db.create_store_submission( user_id=user_id, agent_id=submission_request.agent_id, agent_version=submission_request.agent_version, @@ -398,15 +554,60 @@ async def create_submission( description=submission_request.description, sub_heading=submission_request.sub_heading, categories=submission_request.categories, + changes_summary=submission_request.changes_summary or "Initial Submission", ) - return submission except Exception: logger.exception("Exception occurred whilst creating store submission") - raise + return fastapi.responses.JSONResponse( + status_code=500, + content={"detail": "An error occurred while creating the store submission"}, + ) + + +@router.put( + "/submissions/{store_listing_version_id}", + summary="Edit store submission", + tags=["store", "private"], + dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], + response_model=backend.server.v2.store.model.StoreSubmission, +) +async def edit_submission( + store_listing_version_id: str, + submission_request: backend.server.v2.store.model.StoreSubmissionEditRequest, + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ], +): + """ + Edit an existing store listing submission. + + Args: + store_listing_version_id (str): ID of the store listing version to edit + submission_request (StoreSubmissionRequest): The updated submission details + user_id (str): ID of the authenticated user editing the listing + + Returns: + StoreSubmission: The updated store submission + + Raises: + HTTPException: If there is an error editing the submission + """ + return await backend.server.v2.store.db.edit_store_submission( + user_id=user_id, + store_listing_version_id=store_listing_version_id, + name=submission_request.name, + video_url=submission_request.video_url, + image_urls=submission_request.image_urls, + description=submission_request.description, + sub_heading=submission_request.sub_heading, + categories=submission_request.categories, + changes_summary=submission_request.changes_summary, + ) @router.post( "/submissions/media", + summary="Upload submission media", tags=["store", "private"], dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], ) @@ -415,7 +616,7 @@ async def upload_submission_media( user_id: typing.Annotated[ str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) ], -) -> str: +): """ Upload media (images/videos) for a store listing submission. @@ -434,8 +635,136 @@ async def upload_submission_media( user_id=user_id, file=file ) return media_url - except Exception as e: + except backend.server.v2.store.exceptions.VirusDetectedError as e: + logger.warning(f"Virus detected in uploaded file: {e.threat_name}") + return fastapi.responses.JSONResponse( + status_code=400, + content={ + "detail": f"File rejected due to virus detection: {e.threat_name}", + "error_type": "virus_detected", + "threat_name": e.threat_name, + }, + ) + except backend.server.v2.store.exceptions.VirusScanError as e: + logger.error(f"Virus scanning failed: {str(e)}") + return fastapi.responses.JSONResponse( + status_code=503, + content={ + "detail": "Virus scanning service unavailable. Please try again later.", + "error_type": "virus_scan_failed", + }, + ) + except Exception: logger.exception("Exception occurred whilst uploading submission media") - raise fastapi.HTTPException( - status_code=500, detail=f"Failed to upload media file: {str(e)}" + return fastapi.responses.JSONResponse( + status_code=500, + content={"detail": "An error occurred while uploading the media file"}, + ) + + +@router.post( + "/submissions/generate_image", + summary="Generate submission image", + tags=["store", "private"], + dependencies=[fastapi.Depends(autogpt_libs.auth.middleware.auth_middleware)], +) +async def generate_image( + agent_id: str, + user_id: typing.Annotated[ + str, fastapi.Depends(autogpt_libs.auth.depends.get_user_id) + ], +) -> fastapi.responses.Response: + """ + Generate an image for a store listing submission. + + Args: + agent_id (str): ID of the agent to generate an image for + user_id (str): ID of the authenticated user + + Returns: + JSONResponse: JSON containing the URL of the generated image + """ + try: + agent = await backend.data.graph.get_graph(agent_id, user_id=user_id) + + if not agent: + raise fastapi.HTTPException( + status_code=404, detail=f"Agent with ID {agent_id} not found" + ) + # Use .jpeg here since we are generating JPEG images + filename = f"agent_{agent_id}.jpeg" + + existing_url = await backend.server.v2.store.media.check_media_exists( + user_id, filename + ) + if existing_url: + logger.info(f"Using existing image for agent {agent_id}") + return fastapi.responses.JSONResponse(content={"image_url": existing_url}) + # Generate agent image as JPEG + image = await backend.server.v2.store.image_gen.generate_agent_image( + agent=agent + ) + + # Create UploadFile with the correct filename and content_type + image_file = fastapi.UploadFile( + file=image, + filename=filename, + ) + + image_url = await backend.server.v2.store.media.upload_media( + user_id=user_id, file=image_file, use_file_name=True + ) + + return fastapi.responses.JSONResponse(content={"image_url": image_url}) + except Exception: + logger.exception("Exception occurred whilst generating submission image") + return fastapi.responses.JSONResponse( + status_code=500, + content={"detail": "An error occurred while generating the image"}, + ) + + +@router.get( + "/download/agents/{store_listing_version_id}", + summary="Download agent file", + tags=["store", "public"], +) +async def download_agent_file( + request: fastapi.Request, + store_listing_version_id: str = fastapi.Path( + ..., description="The ID of the agent to download" + ), +) -> fastapi.responses.FileResponse: + """ + Download the agent file by streaming its content. + + Args: + store_listing_version_id (str): The ID of the agent to download + + Returns: + StreamingResponse: A streaming response containing the agent's graph data. + + Raises: + HTTPException: If the agent is not found or an unexpected error occurs. + """ + try: + user_id = get_user_id(await auth_middleware(request)) + except fastapi.HTTPException: + user_id = None + + graph_data = await backend.server.v2.store.db.get_agent( + user_id=user_id, + store_listing_version_id=store_listing_version_id, + ) + file_name = f"agent_{graph_data.id}_v{graph_data.version or 'latest'}.json" + + # Sending graph as a stream (similar to marketplace v1) + with tempfile.NamedTemporaryFile( + mode="w", suffix=".json", delete=False + ) as tmp_file: + tmp_file.write(backend.util.json.dumps(graph_data)) + tmp_file.flush() + + return fastapi.responses.FileResponse( + tmp_file.name, filename=file_name, media_type="application/json" ) diff --git a/autogpt_platform/backend/backend/server/v2/store/routes_test.py b/autogpt_platform/backend/backend/server/v2/store/routes_test.py index ae4496f9eb2f..86eb82e5340d 100644 --- a/autogpt_platform/backend/backend/server/v2/store/routes_test.py +++ b/autogpt_platform/backend/backend/server/v2/store/routes_test.py @@ -1,4 +1,5 @@ import datetime +import json import autogpt_libs.auth.depends import autogpt_libs.auth.middleware @@ -6,22 +7,27 @@ import fastapi.testclient import prisma.enums import pytest_mock +from pytest_snapshot.plugin import Snapshot import backend.server.v2.store.model import backend.server.v2.store.routes +# Using a fixed timestamp for reproducible tests +# 2023 date is intentionally used to ensure tests work regardless of current year +FIXED_NOW = datetime.datetime(2023, 1, 1, 0, 0, 0) + app = fastapi.FastAPI() app.include_router(backend.server.v2.store.routes.router) client = fastapi.testclient.TestClient(app) -def override_auth_middleware(): +def override_auth_middleware() -> dict[str, str]: """Override auth middleware for testing""" return {"sub": "test-user-id"} -def override_get_user_id(): +def override_get_user_id() -> str: """Override get_user_id for testing""" return "test-user-id" @@ -32,7 +38,10 @@ def override_get_user_id(): app.dependency_overrides[autogpt_libs.auth.depends.get_user_id] = override_get_user_id -def test_get_agents_defaults(mocker: pytest_mock.MockFixture): +def test_get_agents_defaults( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.StoreAgentsResponse( agents=[], pagination=backend.server.v2.store.model.Pagination( @@ -52,9 +61,12 @@ def test_get_agents_defaults(mocker: pytest_mock.MockFixture): ) assert data.pagination.total_pages == 0 assert data.agents == [] + + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "def_agts") mock_db_call.assert_called_once_with( featured=False, - creator=None, + creators=None, sorted_by=None, search_query=None, category=None, @@ -63,7 +75,10 @@ def test_get_agents_defaults(mocker: pytest_mock.MockFixture): ) -def test_get_agents_featured(mocker: pytest_mock.MockFixture): +def test_get_agents_featured( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.StoreAgentsResponse( agents=[ backend.server.v2.store.model.StoreAgent( @@ -94,9 +109,11 @@ def test_get_agents_featured(mocker: pytest_mock.MockFixture): ) assert len(data.agents) == 1 assert data.agents[0].slug == "featured-agent" + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "feat_agts") mock_db_call.assert_called_once_with( featured=True, - creator=None, + creators=None, sorted_by=None, search_query=None, category=None, @@ -105,7 +122,10 @@ def test_get_agents_featured(mocker: pytest_mock.MockFixture): ) -def test_get_agents_by_creator(mocker: pytest_mock.MockFixture): +def test_get_agents_by_creator( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.StoreAgentsResponse( agents=[ backend.server.v2.store.model.StoreAgent( @@ -136,9 +156,11 @@ def test_get_agents_by_creator(mocker: pytest_mock.MockFixture): ) assert len(data.agents) == 1 assert data.agents[0].creator == "specific-creator" + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "agts_by_creator") mock_db_call.assert_called_once_with( featured=False, - creator="specific-creator", + creators=["specific-creator"], sorted_by=None, search_query=None, category=None, @@ -147,7 +169,10 @@ def test_get_agents_by_creator(mocker: pytest_mock.MockFixture): ) -def test_get_agents_sorted(mocker: pytest_mock.MockFixture): +def test_get_agents_sorted( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.StoreAgentsResponse( agents=[ backend.server.v2.store.model.StoreAgent( @@ -178,9 +203,11 @@ def test_get_agents_sorted(mocker: pytest_mock.MockFixture): ) assert len(data.agents) == 1 assert data.agents[0].runs == 1000 + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "agts_sorted") mock_db_call.assert_called_once_with( featured=False, - creator=None, + creators=None, sorted_by="runs", search_query=None, category=None, @@ -189,7 +216,10 @@ def test_get_agents_sorted(mocker: pytest_mock.MockFixture): ) -def test_get_agents_search(mocker: pytest_mock.MockFixture): +def test_get_agents_search( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.StoreAgentsResponse( agents=[ backend.server.v2.store.model.StoreAgent( @@ -220,9 +250,11 @@ def test_get_agents_search(mocker: pytest_mock.MockFixture): ) assert len(data.agents) == 1 assert "specific" in data.agents[0].description.lower() + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "agts_search") mock_db_call.assert_called_once_with( featured=False, - creator=None, + creators=None, sorted_by=None, search_query="specific", category=None, @@ -231,7 +263,10 @@ def test_get_agents_search(mocker: pytest_mock.MockFixture): ) -def test_get_agents_category(mocker: pytest_mock.MockFixture): +def test_get_agents_category( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.StoreAgentsResponse( agents=[ backend.server.v2.store.model.StoreAgent( @@ -261,9 +296,11 @@ def test_get_agents_category(mocker: pytest_mock.MockFixture): response.json() ) assert len(data.agents) == 1 + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "agts_category") mock_db_call.assert_called_once_with( featured=False, - creator=None, + creators=None, sorted_by=None, search_query=None, category="test-category", @@ -272,7 +309,10 @@ def test_get_agents_category(mocker: pytest_mock.MockFixture): ) -def test_get_agents_pagination(mocker: pytest_mock.MockFixture): +def test_get_agents_pagination( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.StoreAgentsResponse( agents=[ backend.server.v2.store.model.StoreAgent( @@ -305,9 +345,11 @@ def test_get_agents_pagination(mocker: pytest_mock.MockFixture): assert len(data.agents) == 5 assert data.pagination.current_page == 2 assert data.pagination.page_size == 5 + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "agts_pagination") mock_db_call.assert_called_once_with( featured=False, - creator=None, + creators=None, sorted_by=None, search_query=None, category=None, @@ -334,7 +376,10 @@ def test_get_agents_malformed_request(mocker: pytest_mock.MockFixture): mock_db_call.assert_not_called() -def test_get_agent_details(mocker: pytest_mock.MockFixture): +def test_get_agent_details( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.StoreAgentDetails( store_listing_version_id="test-version-id", slug="test-agent", @@ -349,7 +394,7 @@ def test_get_agent_details(mocker: pytest_mock.MockFixture): runs=100, rating=4.5, versions=["1.0.0", "1.1.0"], - last_updated=datetime.datetime.now(), + last_updated=FIXED_NOW, ) mock_db_call = mocker.patch("backend.server.v2.store.db.get_store_agent_details") mock_db_call.return_value = mocked_value @@ -362,10 +407,15 @@ def test_get_agent_details(mocker: pytest_mock.MockFixture): ) assert data.agent_name == "Test Agent" assert data.creator == "creator1" + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "agt_details") mock_db_call.assert_called_once_with(username="creator1", agent_name="test-agent") -def test_get_creators_defaults(mocker: pytest_mock.MockFixture): +def test_get_creators_defaults( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.CreatorsResponse( creators=[], pagination=backend.server.v2.store.model.Pagination( @@ -386,12 +436,17 @@ def test_get_creators_defaults(mocker: pytest_mock.MockFixture): ) assert data.pagination.total_pages == 0 assert data.creators == [] + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "def_creators") mock_db_call.assert_called_once_with( featured=False, search_query=None, sorted_by=None, page=1, page_size=20 ) -def test_get_creators_pagination(mocker: pytest_mock.MockFixture): +def test_get_creators_pagination( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.CreatorsResponse( creators=[ backend.server.v2.store.model.Creator( @@ -425,6 +480,8 @@ def test_get_creators_pagination(mocker: pytest_mock.MockFixture): assert len(data.creators) == 5 assert data.pagination.current_page == 2 assert data.pagination.page_size == 5 + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "creators_pagination") mock_db_call.assert_called_once_with( featured=False, search_query=None, sorted_by=None, page=2, page_size=5 ) @@ -448,7 +505,10 @@ def test_get_creators_malformed_request(mocker: pytest_mock.MockFixture): mock_db_call.assert_not_called() -def test_get_creator_details(mocker: pytest_mock.MockFixture): +def test_get_creator_details( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.CreatorDetails( name="Test User", username="creator1", @@ -468,17 +528,22 @@ def test_get_creator_details(mocker: pytest_mock.MockFixture): data = backend.server.v2.store.model.CreatorDetails.model_validate(response.json()) assert data.username == "creator1" assert data.name == "Test User" + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "creator_details") mock_db_call.assert_called_once_with(username="creator1") -def test_get_submissions_success(mocker: pytest_mock.MockFixture): +def test_get_submissions_success( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.StoreSubmissionsResponse( submissions=[ backend.server.v2.store.model.StoreSubmission( name="Test Agent", description="Test agent description", image_urls=["test.jpg"], - date_submitted=datetime.datetime.now(), + date_submitted=FIXED_NOW, status=prisma.enums.SubmissionStatus.APPROVED, runs=50, rating=4.2, @@ -486,6 +551,8 @@ def test_get_submissions_success(mocker: pytest_mock.MockFixture): agent_version=1, sub_heading="Test agent subheading", slug="test-agent", + video_url="test.mp4", + categories=["test-category"], ) ], pagination=backend.server.v2.store.model.Pagination( @@ -507,10 +574,15 @@ def test_get_submissions_success(mocker: pytest_mock.MockFixture): assert len(data.submissions) == 1 assert data.submissions[0].name == "Test Agent" assert data.pagination.current_page == 1 + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "sub_success") mock_db_call.assert_called_once_with(user_id="test-user-id", page=1, page_size=20) -def test_get_submissions_pagination(mocker: pytest_mock.MockFixture): +def test_get_submissions_pagination( + mocker: pytest_mock.MockFixture, + snapshot: Snapshot, +) -> None: mocked_value = backend.server.v2.store.model.StoreSubmissionsResponse( submissions=[], pagination=backend.server.v2.store.model.Pagination( @@ -531,6 +603,8 @@ def test_get_submissions_pagination(mocker: pytest_mock.MockFixture): ) assert data.pagination.current_page == 2 assert data.pagination.page_size == 5 + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(response.json(), indent=2), "sub_pagination") mock_db_call.assert_called_once_with(user_id="test-user-id", page=2, page_size=5) diff --git a/autogpt_platform/backend/backend/server/v2/turnstile/__init__.py b/autogpt_platform/backend/backend/server/v2/turnstile/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/backend/backend/server/v2/turnstile/models.py b/autogpt_platform/backend/backend/server/v2/turnstile/models.py new file mode 100644 index 000000000000..9410b8951166 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/turnstile/models.py @@ -0,0 +1,30 @@ +from typing import Optional + +from pydantic import BaseModel, Field + + +class TurnstileVerifyRequest(BaseModel): + """Request model for verifying a Turnstile token.""" + + token: str = Field(description="The Turnstile token to verify") + action: Optional[str] = Field( + default=None, description="The action that the user is attempting to perform" + ) + + +class TurnstileVerifyResponse(BaseModel): + """Response model for the Turnstile verification endpoint.""" + + success: bool = Field(description="Whether the token verification was successful") + error: Optional[str] = Field( + default=None, description="Error message if verification failed" + ) + challenge_timestamp: Optional[str] = Field( + default=None, description="Timestamp of the challenge (ISO format)" + ) + hostname: Optional[str] = Field( + default=None, description="Hostname of the site where the challenge was solved" + ) + action: Optional[str] = Field( + default=None, description="The action associated with this verification" + ) diff --git a/autogpt_platform/backend/backend/server/v2/turnstile/routes.py b/autogpt_platform/backend/backend/server/v2/turnstile/routes.py new file mode 100644 index 000000000000..7a4fe5bafa7c --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/turnstile/routes.py @@ -0,0 +1,112 @@ +import logging + +import aiohttp +from fastapi import APIRouter + +from backend.util.settings import Settings + +from .models import TurnstileVerifyRequest, TurnstileVerifyResponse + +logger = logging.getLogger(__name__) + +router = APIRouter() +settings = Settings() + + +@router.post( + "/verify", response_model=TurnstileVerifyResponse, summary="Verify Turnstile Token" +) +async def verify_turnstile_token( + request: TurnstileVerifyRequest, +) -> TurnstileVerifyResponse: + """ + Verify a Cloudflare Turnstile token. + This endpoint verifies a token returned by the Cloudflare Turnstile challenge + on the client side. It returns whether the verification was successful. + """ + logger.info(f"Verifying Turnstile token for action: {request.action}") + return await verify_token(request) + + +async def verify_token(request: TurnstileVerifyRequest) -> TurnstileVerifyResponse: + """ + Verify a Cloudflare Turnstile token by making a request to the Cloudflare API. + """ + # Get the secret key from settings + turnstile_secret_key = settings.secrets.turnstile_secret_key + turnstile_verify_url = settings.secrets.turnstile_verify_url + + if not turnstile_secret_key: + logger.error( + "Turnstile secret key missing. Set TURNSTILE_SECRET_KEY to enable verification." + ) + return TurnstileVerifyResponse( + success=False, + error="CONFIGURATION_ERROR", + challenge_timestamp=None, + hostname=None, + action=None, + ) + + try: + async with aiohttp.ClientSession() as session: + payload = { + "secret": turnstile_secret_key, + "response": request.token, + } + + if request.action: + payload["action"] = request.action + + logger.debug(f"Verifying Turnstile token with action: {request.action}") + + async with session.post( + turnstile_verify_url, + data=payload, + timeout=aiohttp.ClientTimeout(total=10), + ) as response: + if response.status != 200: + error_text = await response.text() + logger.error(f"Turnstile API error: {error_text}") + return TurnstileVerifyResponse( + success=False, + error=f"API_ERROR: {response.status}", + challenge_timestamp=None, + hostname=None, + action=None, + ) + + data = await response.json() + logger.debug(f"Turnstile API response: {data}") + + # Parse the response and return a structured object + return TurnstileVerifyResponse( + success=data.get("success", False), + error=( + data.get("error-codes", None)[0] + if data.get("error-codes") + else None + ), + challenge_timestamp=data.get("challenge_timestamp"), + hostname=data.get("hostname"), + action=data.get("action"), + ) + + except aiohttp.ClientError as e: + logger.error(f"Connection error to Turnstile API: {str(e)}") + return TurnstileVerifyResponse( + success=False, + error=f"CONNECTION_ERROR: {str(e)}", + challenge_timestamp=None, + hostname=None, + action=None, + ) + except Exception as e: + logger.error(f"Unexpected error in Turnstile verification: {str(e)}") + return TurnstileVerifyResponse( + success=False, + error=f"UNEXPECTED_ERROR: {str(e)}", + challenge_timestamp=None, + hostname=None, + action=None, + ) diff --git a/autogpt_platform/backend/backend/server/v2/turnstile/routes_test.py b/autogpt_platform/backend/backend/server/v2/turnstile/routes_test.py new file mode 100644 index 000000000000..5a9260131fb2 --- /dev/null +++ b/autogpt_platform/backend/backend/server/v2/turnstile/routes_test.py @@ -0,0 +1,32 @@ +import fastapi +import fastapi.testclient +import pytest_mock + +import backend.server.v2.turnstile.routes as turnstile_routes + +app = fastapi.FastAPI() +app.include_router(turnstile_routes.router) + +client = fastapi.testclient.TestClient(app) + + +def test_verify_turnstile_token_no_secret_key(mocker: pytest_mock.MockFixture) -> None: + """Test token verification without secret key configured""" + # Mock the settings with no secret key + mock_settings = mocker.patch("backend.server.v2.turnstile.routes.settings") + mock_settings.secrets.turnstile_secret_key = None + + request_data = {"token": "test_token", "action": "login"} + response = client.post("/verify", json=request_data) + + assert response.status_code == 200 + response_data = response.json() + assert response_data["success"] is False + assert response_data["error"] == "CONFIGURATION_ERROR" + + +def test_verify_turnstile_token_invalid_request() -> None: + """Test token verification with invalid request data""" + # Missing token + response = client.post("/verify", json={"action": "login"}) + assert response.status_code == 422 diff --git a/autogpt_platform/backend/backend/server/ws_api.py b/autogpt_platform/backend/backend/server/ws_api.py index a6da64b8e53c..65b747c8b237 100644 --- a/autogpt_platform/backend/backend/server/ws_api.py +++ b/autogpt_platform/backend/backend/server/ws_api.py @@ -1,17 +1,24 @@ import asyncio import logging from contextlib import asynccontextmanager +from typing import Protocol +import pydantic import uvicorn from autogpt_libs.auth import parse_jwt_token from fastapi import Depends, FastAPI, WebSocket, WebSocketDisconnect from starlette.middleware.cors import CORSMiddleware -from backend.data import redis from backend.data.execution import AsyncRedisExecutionEventBus from backend.data.user import DEFAULT_USER_ID from backend.server.conn_manager import ConnectionManager -from backend.server.model import ExecutionSubscription, Methods, WsMessage +from backend.server.model import ( + WSMessage, + WSMethod, + WSSubscribeGraphExecutionRequest, + WSSubscribeGraphExecutionsRequest, +) +from backend.util.retry import continuous_retry from backend.util.service import AppProcess from backend.util.settings import AppEnvironment, Config, Settings @@ -39,17 +46,11 @@ def get_connection_manager(): return _connection_manager +@continuous_retry() async def event_broadcaster(manager: ConnectionManager): - try: - redis.connect() - event_queue = AsyncRedisExecutionEventBus() - async for event in event_queue.listen(): - await manager.send_execution_result(event) - except Exception as e: - logger.exception(f"Event broadcaster error: {e}") - raise - finally: - redis.disconnect() + event_queue = AsyncRedisExecutionEventBus() + async for event in event_queue.listen("*"): + await manager.send_execution_update(event) async def authenticate_websocket(websocket: WebSocket) -> str: @@ -73,57 +74,138 @@ async def authenticate_websocket(websocket: WebSocket) -> str: return "" +# ===================== Message Handlers ===================== # + + +class WSMessageHandler(Protocol): + async def __call__( + self, + connection_manager: ConnectionManager, + websocket: WebSocket, + user_id: str, + message: WSMessage, + ): ... + + async def handle_subscribe( - websocket: WebSocket, manager: ConnectionManager, message: WsMessage + connection_manager: ConnectionManager, + websocket: WebSocket, + user_id: str, + message: WSMessage, ): if not message.data: await websocket.send_text( - WsMessage( - method=Methods.ERROR, + WSMessage( + method=WSMethod.ERROR, success=False, error="Subscription data missing", ).model_dump_json() ) + return + + # Verify that user has read access to graph + # if not get_db_client().get_graph( + # graph_id=sub_req.graph_id, + # version=sub_req.graph_version, + # user_id=user_id, + # ): + # await websocket.send_text( + # WsMessage( + # method=Methods.ERROR, + # success=False, + # error="Access denied", + # ).model_dump_json() + # ) + # return + + if message.method == WSMethod.SUBSCRIBE_GRAPH_EXEC: + sub_req = WSSubscribeGraphExecutionRequest.model_validate(message.data) + channel_key = await connection_manager.subscribe_graph_exec( + user_id=user_id, + graph_exec_id=sub_req.graph_exec_id, + websocket=websocket, + ) + + elif message.method == WSMethod.SUBSCRIBE_GRAPH_EXECS: + sub_req = WSSubscribeGraphExecutionsRequest.model_validate(message.data) + channel_key = await connection_manager.subscribe_graph_execs( + user_id=user_id, + graph_id=sub_req.graph_id, + websocket=websocket, + ) + else: - ex_sub = ExecutionSubscription.model_validate(message.data) - await manager.subscribe(ex_sub.graph_id, websocket) - logger.debug(f"New execution subscription for graph {ex_sub.graph_id}") - await websocket.send_text( - WsMessage( - method=Methods.SUBSCRIBE, - success=True, - channel=ex_sub.graph_id, - ).model_dump_json() + raise ValueError( + f"{handle_subscribe.__name__} can't handle '{message.method}' messages" ) + logger.debug(f"New subscription on channel {channel_key} for user #{user_id}") + await websocket.send_text( + WSMessage( + method=message.method, + success=True, + channel=channel_key, + ).model_dump_json() + ) + async def handle_unsubscribe( - websocket: WebSocket, manager: ConnectionManager, message: WsMessage + connection_manager: ConnectionManager, + websocket: WebSocket, + user_id: str, + message: WSMessage, ): if not message.data: await websocket.send_text( - WsMessage( - method=Methods.ERROR, + WSMessage( + method=WSMethod.ERROR, success=False, error="Subscription data missing", ).model_dump_json() ) - else: - ex_sub = ExecutionSubscription.model_validate(message.data) - await manager.unsubscribe(ex_sub.graph_id, websocket) - logger.debug(f"Removed execution subscription for graph {ex_sub.graph_id}") - await websocket.send_text( - WsMessage( - method=Methods.UNSUBSCRIBE, - success=True, - channel=ex_sub.graph_id, - ).model_dump_json() - ) + return + unsub_req = WSSubscribeGraphExecutionRequest.model_validate(message.data) + channel_key = await connection_manager.unsubscribe_graph_exec( + user_id=user_id, + graph_exec_id=unsub_req.graph_exec_id, + websocket=websocket, + ) -@app.get("/") -async def health(): - return {"status": "healthy"} + logger.debug(f"Removed subscription on channel {channel_key} for user #{user_id}") + await websocket.send_text( + WSMessage( + method=WSMethod.UNSUBSCRIBE, + success=True, + channel=channel_key, + ).model_dump_json() + ) + + +async def handle_heartbeat( + connection_manager: ConnectionManager, + websocket: WebSocket, + user_id: str, + message: WSMessage, +): + await websocket.send_json( + { + "method": WSMethod.HEARTBEAT.value, + "data": "pong", + "success": True, + } + ) + + +_MSG_HANDLERS: dict[WSMethod, WSMessageHandler] = { + WSMethod.HEARTBEAT: handle_heartbeat, + WSMethod.SUBSCRIBE_GRAPH_EXEC: handle_subscribe, + WSMethod.SUBSCRIBE_GRAPH_EXECS: handle_subscribe, + WSMethod.UNSUBSCRIBE: handle_unsubscribe, +} + + +# ===================== WebSocket Server ===================== # @app.websocket("/ws") @@ -133,25 +215,59 @@ async def websocket_router( user_id = await authenticate_websocket(websocket) if not user_id: return - await manager.connect(websocket) + await manager.connect_socket(websocket) try: while True: data = await websocket.receive_text() - message = WsMessage.model_validate_json(data) - - if message.method == Methods.HEARTBEAT: - await websocket.send_json( - {"method": Methods.HEARTBEAT.value, "data": "pong", "success": True} + try: + message = WSMessage.model_validate_json(data) + except pydantic.ValidationError as e: + logger.error( + "Invalid WebSocket message from user #%s: %s", + user_id, + e, + ) + await websocket.send_text( + WSMessage( + method=WSMethod.ERROR, + success=False, + error=("Invalid message format. Review the schema and retry"), + ).model_dump_json() ) continue - if message.method == Methods.SUBSCRIBE: - await handle_subscribe(websocket, manager, message) - - elif message.method == Methods.UNSUBSCRIBE: - await handle_unsubscribe(websocket, manager, message) + try: + if message.method in _MSG_HANDLERS: + await _MSG_HANDLERS[message.method]( + connection_manager=manager, + websocket=websocket, + user_id=user_id, + message=message, + ) + continue + except pydantic.ValidationError as e: + logger.error( + "Validation error while handling '%s' for user #%s: %s", + message.method.value, + user_id, + e, + ) + await websocket.send_text( + WSMessage( + method=WSMethod.ERROR, + success=False, + error="Invalid message data. Refer to the API schema", + ).model_dump_json() + ) + continue + except Exception as e: + logger.error( + f"Error while handling '{message.method.value}' message " + f"for user #{user_id}: {e}" + ) + continue - elif message.method == Methods.ERROR: + if message.method == WSMethod.ERROR: logger.error(f"WebSocket Error message received: {message.data}") else: @@ -160,18 +276,23 @@ async def websocket_router( f"{message.data}" ) await websocket.send_text( - WsMessage( - method=Methods.ERROR, + WSMessage( + method=WSMethod.ERROR, success=False, error="Message type is not processed by the server", ).model_dump_json() ) except WebSocketDisconnect: - manager.disconnect(websocket) + manager.disconnect_socket(websocket) logger.debug("WebSocket client disconnected") +@app.get("/") +async def health(): + return {"status": "healthy"} + + class WebsocketServer(AppProcess): def run(self): logger.info(f"CORS allow origins: {settings.config.backend_cors_allow_origins}") @@ -182,8 +303,14 @@ def run(self): allow_methods=["*"], allow_headers=["*"], ) + uvicorn.run( server_app, host=Config().websocket_server_host, port=Config().websocket_server_port, + log_config=None, ) + + def cleanup(self): + super().cleanup() + logger.info(f"[{self.service_name}] ⏳ Shutting down WebSocket Server...") diff --git a/autogpt_platform/backend/backend/server/ws_api_test.py b/autogpt_platform/backend/backend/server/ws_api_test.py new file mode 100644 index 000000000000..c9c27eb0867e --- /dev/null +++ b/autogpt_platform/backend/backend/server/ws_api_test.py @@ -0,0 +1,241 @@ +import json +from typing import cast +from unittest.mock import AsyncMock + +import pytest +from fastapi import WebSocket, WebSocketDisconnect +from pytest_snapshot.plugin import Snapshot + +from backend.data.user import DEFAULT_USER_ID +from backend.server.conn_manager import ConnectionManager +from backend.server.ws_api import ( + WSMessage, + WSMethod, + handle_subscribe, + handle_unsubscribe, + websocket_router, +) + + +@pytest.fixture +def mock_websocket() -> AsyncMock: + mock = AsyncMock(spec=WebSocket) + mock.query_params = {} # Add query_params attribute for authentication + return mock + + +@pytest.fixture +def mock_manager() -> AsyncMock: + return AsyncMock(spec=ConnectionManager) + + +@pytest.mark.asyncio +async def test_websocket_router_subscribe( + mock_websocket: AsyncMock, mock_manager: AsyncMock, snapshot: Snapshot, mocker +) -> None: + # Mock the authenticate_websocket function to ensure it returns a valid user_id + mocker.patch( + "backend.server.ws_api.authenticate_websocket", return_value=DEFAULT_USER_ID + ) + + mock_websocket.receive_text.side_effect = [ + WSMessage( + method=WSMethod.SUBSCRIBE_GRAPH_EXEC, + data={"graph_exec_id": "test-graph-exec-1"}, + ).model_dump_json(), + WebSocketDisconnect(), + ] + mock_manager.subscribe_graph_exec.return_value = ( + f"{DEFAULT_USER_ID}|graph_exec#test-graph-exec-1" + ) + + await websocket_router( + cast(WebSocket, mock_websocket), cast(ConnectionManager, mock_manager) + ) + + mock_manager.connect_socket.assert_called_once_with(mock_websocket) + mock_manager.subscribe_graph_exec.assert_called_once_with( + user_id=DEFAULT_USER_ID, + graph_exec_id="test-graph-exec-1", + websocket=mock_websocket, + ) + mock_websocket.send_text.assert_called_once() + assert ( + '"method":"subscribe_graph_execution"' + in mock_websocket.send_text.call_args[0][0] + ) + assert '"success":true' in mock_websocket.send_text.call_args[0][0] + + # Capture and snapshot the WebSocket response message + sent_message = mock_websocket.send_text.call_args[0][0] + parsed_message = json.loads(sent_message) + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(parsed_message, indent=2, sort_keys=True), "sub") + + mock_manager.disconnect_socket.assert_called_once_with(mock_websocket) + + +@pytest.mark.asyncio +async def test_websocket_router_unsubscribe( + mock_websocket: AsyncMock, mock_manager: AsyncMock, snapshot: Snapshot, mocker +) -> None: + # Mock the authenticate_websocket function to ensure it returns a valid user_id + mocker.patch( + "backend.server.ws_api.authenticate_websocket", return_value=DEFAULT_USER_ID + ) + + mock_websocket.receive_text.side_effect = [ + WSMessage( + method=WSMethod.UNSUBSCRIBE, + data={"graph_exec_id": "test-graph-exec-1"}, + ).model_dump_json(), + WebSocketDisconnect(), + ] + mock_manager.unsubscribe_graph_exec.return_value = ( + f"{DEFAULT_USER_ID}|graph_exec#test-graph-exec-1" + ) + + await websocket_router( + cast(WebSocket, mock_websocket), cast(ConnectionManager, mock_manager) + ) + + mock_manager.connect_socket.assert_called_once_with(mock_websocket) + mock_manager.unsubscribe_graph_exec.assert_called_once_with( + user_id=DEFAULT_USER_ID, + graph_exec_id="test-graph-exec-1", + websocket=mock_websocket, + ) + mock_websocket.send_text.assert_called_once() + assert '"method":"unsubscribe"' in mock_websocket.send_text.call_args[0][0] + assert '"success":true' in mock_websocket.send_text.call_args[0][0] + + # Capture and snapshot the WebSocket response message + sent_message = mock_websocket.send_text.call_args[0][0] + parsed_message = json.loads(sent_message) + snapshot.snapshot_dir = "snapshots" + snapshot.assert_match(json.dumps(parsed_message, indent=2, sort_keys=True), "unsub") + + mock_manager.disconnect_socket.assert_called_once_with(mock_websocket) + + +@pytest.mark.asyncio +async def test_websocket_router_invalid_method( + mock_websocket: AsyncMock, mock_manager: AsyncMock, mocker +) -> None: + # Mock the authenticate_websocket function to ensure it returns a valid user_id + mocker.patch( + "backend.server.ws_api.authenticate_websocket", return_value=DEFAULT_USER_ID + ) + + mock_websocket.receive_text.side_effect = [ + WSMessage(method=WSMethod.GRAPH_EXECUTION_EVENT).model_dump_json(), + WebSocketDisconnect(), + ] + + await websocket_router( + cast(WebSocket, mock_websocket), cast(ConnectionManager, mock_manager) + ) + + mock_manager.connect_socket.assert_called_once_with(mock_websocket) + mock_websocket.send_text.assert_called_once() + assert '"method":"error"' in mock_websocket.send_text.call_args[0][0] + assert '"success":false' in mock_websocket.send_text.call_args[0][0] + mock_manager.disconnect_socket.assert_called_once_with(mock_websocket) + + +@pytest.mark.asyncio +async def test_handle_subscribe_success( + mock_websocket: AsyncMock, mock_manager: AsyncMock +) -> None: + message = WSMessage( + method=WSMethod.SUBSCRIBE_GRAPH_EXEC, + data={"graph_exec_id": "test-graph-exec-id"}, + ) + mock_manager.subscribe_graph_exec.return_value = ( + "user-1|graph_exec#test-graph-exec-id" + ) + + await handle_subscribe( + connection_manager=cast(ConnectionManager, mock_manager), + websocket=cast(WebSocket, mock_websocket), + user_id="user-1", + message=message, + ) + + mock_manager.subscribe_graph_exec.assert_called_once_with( + user_id="user-1", + graph_exec_id="test-graph-exec-id", + websocket=mock_websocket, + ) + mock_websocket.send_text.assert_called_once() + assert ( + '"method":"subscribe_graph_execution"' + in mock_websocket.send_text.call_args[0][0] + ) + assert '"success":true' in mock_websocket.send_text.call_args[0][0] + + +@pytest.mark.asyncio +async def test_handle_subscribe_missing_data( + mock_websocket: AsyncMock, mock_manager: AsyncMock +) -> None: + message = WSMessage(method=WSMethod.SUBSCRIBE_GRAPH_EXEC) + + await handle_subscribe( + connection_manager=cast(ConnectionManager, mock_manager), + websocket=cast(WebSocket, mock_websocket), + user_id="user-1", + message=message, + ) + + mock_manager.subscribe_graph_exec.assert_not_called() + mock_websocket.send_text.assert_called_once() + assert '"method":"error"' in mock_websocket.send_text.call_args[0][0] + assert '"success":false' in mock_websocket.send_text.call_args[0][0] + + +@pytest.mark.asyncio +async def test_handle_unsubscribe_success( + mock_websocket: AsyncMock, mock_manager: AsyncMock +) -> None: + message = WSMessage( + method=WSMethod.UNSUBSCRIBE, data={"graph_exec_id": "test-graph-exec-id"} + ) + mock_manager.unsubscribe_graph_exec.return_value = ( + "user-1|graph_exec#test-graph-exec-id" + ) + + await handle_unsubscribe( + connection_manager=cast(ConnectionManager, mock_manager), + websocket=cast(WebSocket, mock_websocket), + user_id="user-1", + message=message, + ) + + mock_manager.unsubscribe_graph_exec.assert_called_once_with( + user_id="user-1", + graph_exec_id="test-graph-exec-id", + websocket=mock_websocket, + ) + mock_websocket.send_text.assert_called_once() + assert '"method":"unsubscribe"' in mock_websocket.send_text.call_args[0][0] + assert '"success":true' in mock_websocket.send_text.call_args[0][0] + + +@pytest.mark.asyncio +async def test_handle_unsubscribe_missing_data( + mock_websocket: AsyncMock, mock_manager: AsyncMock +) -> None: + message = WSMessage(method=WSMethod.UNSUBSCRIBE) + + await handle_unsubscribe( + connection_manager=cast(ConnectionManager, mock_manager), + websocket=cast(WebSocket, mock_websocket), + user_id="user-1", + message=message, + ) + + mock_manager._unsubscribe.assert_not_called() + mock_websocket.send_text.assert_called_once() + assert '"method":"error"' in mock_websocket.send_text.call_args[0][0] + assert '"success":false' in mock_websocket.send_text.call_args[0][0] diff --git a/autogpt_platform/backend/backend/usecases/block_autogen.py b/autogpt_platform/backend/backend/usecases/block_autogen.py index dc34b79f3278..7ccd766c5edf 100644 --- a/autogpt_platform/backend/backend/usecases/block_autogen.py +++ b/autogpt_platform/backend/backend/usecases/block_autogen.py @@ -1,13 +1,12 @@ from pathlib import Path -from prisma.models import User - from backend.blocks.basic import StoreValueBlock from backend.blocks.block import BlockInstallationBlock from backend.blocks.http import SendWebRequestBlock from backend.blocks.llm import AITextGeneratorBlock from backend.blocks.text import ExtractTextInformationBlock, FillTextTemplateBlock from backend.data.graph import Graph, Link, Node, create_graph +from backend.data.model import User from backend.data.user import get_or_create_user from backend.util.test import SpinTestServer, wait_execution @@ -253,12 +252,13 @@ async def block_autogen_agent(): test_graph = await create_graph(create_test_graph(), user_id=test_user.id) input_data = {"input": "Write me a block that writes a string into a file."} response = await server.agent_server.test_execute_graph( - test_graph.id, input_data, test_user.id + graph_id=test_graph.id, + user_id=test_user.id, + node_input=input_data, ) print(response) result = await wait_execution( - graph_id=test_graph.id, - graph_exec_id=response["id"], + graph_exec_id=response.graph_exec_id, timeout=1200, user_id=test_user.id, ) diff --git a/autogpt_platform/backend/backend/usecases/reddit_marketing.py b/autogpt_platform/backend/backend/usecases/reddit_marketing.py index 8ea2f651f30e..1ec381d618ad 100644 --- a/autogpt_platform/backend/backend/usecases/reddit_marketing.py +++ b/autogpt_platform/backend/backend/usecases/reddit_marketing.py @@ -1,9 +1,8 @@ -from prisma.models import User - from backend.blocks.llm import AIStructuredResponseGeneratorBlock from backend.blocks.reddit import GetRedditPostsBlock, PostRedditCommentBlock from backend.blocks.text import FillTextTemplateBlock, MatchTextPatternBlock from backend.data.graph import Graph, Link, Node, create_graph +from backend.data.model import User from backend.data.user import get_or_create_user from backend.util.test import SpinTestServer, wait_execution @@ -157,10 +156,12 @@ async def reddit_marketing_agent(): test_graph = await create_graph(create_test_graph(), user_id=test_user.id) input_data = {"subreddit": "AutoGPT"} response = await server.agent_server.test_execute_graph( - test_graph.id, input_data, test_user.id + graph_id=test_graph.id, + user_id=test_user.id, + node_input=input_data, ) print(response) - result = await wait_execution(test_user.id, test_graph.id, response["id"], 120) + result = await wait_execution(test_user.id, response.graph_exec_id, 120) print(result) diff --git a/autogpt_platform/backend/backend/usecases/sample.py b/autogpt_platform/backend/backend/usecases/sample.py index eb6ab6211f01..234a39c9765f 100644 --- a/autogpt_platform/backend/backend/usecases/sample.py +++ b/autogpt_platform/backend/backend/usecases/sample.py @@ -1,19 +1,26 @@ -from prisma.models import User - -from backend.blocks.basic import AgentInputBlock, PrintToConsoleBlock +from backend.blocks.basic import StoreValueBlock +from backend.blocks.io import AgentInputBlock from backend.blocks.text import FillTextTemplateBlock from backend.data import graph from backend.data.graph import create_graph +from backend.data.model import User from backend.data.user import get_or_create_user from backend.util.test import SpinTestServer, wait_execution -async def create_test_user() -> User: - test_user_data = { - "sub": "ef3b97d7-1161-4eb4-92b2-10c24fb154c1", - "email": "testuser#example.com", - "name": "Test User", - } +async def create_test_user(alt_user: bool = False) -> User: + if alt_user: + test_user_data = { + "sub": "3e53486c-cf57-477e-ba2a-cb02dc828e1b", + "email": "testuser2@example.com", + "name": "Test User 2", + } + else: + test_user_data = { + "sub": "ef3b97d7-1161-4eb4-92b2-10c24fb154c1", + "email": "testuser@example.com", + "name": "Test User", + } user = await get_or_create_user(test_user_data) return user @@ -22,7 +29,7 @@ def create_test_graph() -> graph.Graph: """ InputBlock \ - ---- FillTextTemplateBlock ---- PrintToConsoleBlock + ---- FillTextTemplateBlock ---- StoreValueBlock / InputBlock """ @@ -33,16 +40,19 @@ def create_test_graph() -> graph.Graph: ), graph.Node( block_id=AgentInputBlock().id, - input_default={"name": "input_2"}, + input_default={ + "name": "input_2", + "description": "This is my description of this parameter", + }, ), graph.Node( block_id=FillTextTemplateBlock().id, input_default={ - "format": "{a}, {b}{c}", + "format": "{{a}}, {{b}}{{c}}", "values_#_c": "!!!", }, ), - graph.Node(block_id=PrintToConsoleBlock().id), + graph.Node(block_id=StoreValueBlock().id), ] links = [ graph.Link( @@ -61,13 +71,13 @@ def create_test_graph() -> graph.Graph: source_id=nodes[2].id, sink_id=nodes[3].id, source_name="output", - sink_name="text", + sink_name="input", ), ] return graph.Graph( name="TestGraph", - description="Test graph", + description="Test graph description", nodes=nodes, links=links, ) @@ -79,11 +89,11 @@ async def sample_agent(): test_graph = await create_graph(create_test_graph(), test_user.id) input_data = {"input_1": "Hello", "input_2": "World"} response = await server.agent_server.test_execute_graph( - test_graph.id, input_data, test_user.id + graph_id=test_graph.id, + user_id=test_user.id, + node_input=input_data, ) - print(response) - result = await wait_execution(test_user.id, test_graph.id, response["id"], 10) - print(result) + await wait_execution(test_user.id, response.graph_exec_id, 10) if __name__ == "__main__": diff --git a/autogpt_platform/backend/backend/util/clients.py b/autogpt_platform/backend/backend/util/clients.py new file mode 100644 index 000000000000..cdc66a807d82 --- /dev/null +++ b/autogpt_platform/backend/backend/util/clients.py @@ -0,0 +1,164 @@ +""" +Centralized service client helpers with thread caching. +""" + +from functools import cache +from typing import TYPE_CHECKING + +from autogpt_libs.utils.cache import async_cache, thread_cached + +from backend.util.settings import Settings + +settings = Settings() + +if TYPE_CHECKING: + from supabase import AClient, Client + + from backend.data.execution import ( + AsyncRedisExecutionEventBus, + RedisExecutionEventBus, + ) + from backend.data.rabbitmq import AsyncRabbitMQ, SyncRabbitMQ + from backend.executor import DatabaseManagerAsyncClient, DatabaseManagerClient + from backend.executor.scheduler import SchedulerClient + from backend.integrations.credentials_store import IntegrationCredentialsStore + from backend.notifications.notifications import NotificationManagerClient + + +@thread_cached +def get_database_manager_client() -> "DatabaseManagerClient": + """Get a thread-cached DatabaseManagerClient with request retry enabled.""" + from backend.executor import DatabaseManagerClient + from backend.util.service import get_service_client + + return get_service_client(DatabaseManagerClient, request_retry=True) + + +@thread_cached +def get_database_manager_async_client() -> "DatabaseManagerAsyncClient": + """Get a thread-cached DatabaseManagerAsyncClient with request retry enabled.""" + from backend.executor import DatabaseManagerAsyncClient + from backend.util.service import get_service_client + + return get_service_client(DatabaseManagerAsyncClient, request_retry=True) + + +@thread_cached +def get_scheduler_client() -> "SchedulerClient": + """Get a thread-cached SchedulerClient.""" + from backend.executor.scheduler import SchedulerClient + from backend.util.service import get_service_client + + return get_service_client(SchedulerClient) + + +@thread_cached +def get_notification_manager_client() -> "NotificationManagerClient": + """Get a thread-cached NotificationManagerClient.""" + from backend.notifications.notifications import NotificationManagerClient + from backend.util.service import get_service_client + + return get_service_client(NotificationManagerClient) + + +# ============ Execution Event Bus Helpers ============ # + + +@thread_cached +def get_execution_event_bus() -> "RedisExecutionEventBus": + """Get a thread-cached RedisExecutionEventBus.""" + from backend.data.execution import RedisExecutionEventBus + + return RedisExecutionEventBus() + + +@thread_cached +def get_async_execution_event_bus() -> "AsyncRedisExecutionEventBus": + """Get a thread-cached AsyncRedisExecutionEventBus.""" + from backend.data.execution import AsyncRedisExecutionEventBus + + return AsyncRedisExecutionEventBus() + + +# ============ Execution Queue Helpers ============ # + + +@thread_cached +def get_execution_queue() -> "SyncRabbitMQ": + """Get a thread-cached SyncRabbitMQ execution queue client.""" + from backend.data.rabbitmq import SyncRabbitMQ + from backend.executor.utils import create_execution_queue_config + + client = SyncRabbitMQ(create_execution_queue_config()) + client.connect() + return client + + +@thread_cached +async def get_async_execution_queue() -> "AsyncRabbitMQ": + """Get a thread-cached AsyncRabbitMQ execution queue client.""" + from backend.data.rabbitmq import AsyncRabbitMQ + from backend.executor.utils import create_execution_queue_config + + client = AsyncRabbitMQ(create_execution_queue_config()) + await client.connect() + return client + + +# ============ Integration Credentials Store ============ # + + +@thread_cached +def get_integration_credentials_store() -> "IntegrationCredentialsStore": + """Get a thread-cached IntegrationCredentialsStore.""" + from backend.integrations.credentials_store import IntegrationCredentialsStore + + return IntegrationCredentialsStore() + + +# ============ Supabase Clients ============ # + + +@cache +def get_supabase() -> "Client": + """Get a process-cached synchronous Supabase client instance.""" + from supabase import create_client + + return create_client( + settings.secrets.supabase_url, settings.secrets.supabase_service_role_key + ) + + +@async_cache +async def get_async_supabase() -> "AClient": + """Get a process-cached asynchronous Supabase client instance.""" + from supabase import create_async_client + + return await create_async_client( + settings.secrets.supabase_url, settings.secrets.supabase_service_role_key + ) + + +# ============ Notification Queue Helpers ============ # + + +@thread_cached +def get_notification_queue() -> "SyncRabbitMQ": + """Get a thread-cached SyncRabbitMQ notification queue client.""" + from backend.data.rabbitmq import SyncRabbitMQ + from backend.notifications.notifications import create_notification_config + + client = SyncRabbitMQ(create_notification_config()) + client.connect() + return client + + +@thread_cached +async def get_async_notification_queue() -> "AsyncRabbitMQ": + """Get a thread-cached AsyncRabbitMQ notification queue client.""" + from backend.data.rabbitmq import AsyncRabbitMQ + from backend.notifications.notifications import create_notification_config + + client = AsyncRabbitMQ(create_notification_config()) + await client.connect() + return client diff --git a/autogpt_platform/backend/backend/util/cloud_storage.py b/autogpt_platform/backend/backend/util/cloud_storage.py new file mode 100644 index 000000000000..27acc605bb6e --- /dev/null +++ b/autogpt_platform/backend/backend/util/cloud_storage.py @@ -0,0 +1,554 @@ +""" +Cloud storage utilities for handling various cloud storage providers. +""" + +import asyncio +import logging +import os.path +import uuid +from datetime import datetime, timedelta, timezone +from typing import Tuple + +from gcloud.aio import storage as async_gcs_storage +from google.cloud import storage as gcs_storage + +from backend.util.settings import Config + +logger = logging.getLogger(__name__) + + +class CloudStorageConfig: + """Configuration for cloud storage providers.""" + + def __init__(self): + config = Config() + + # GCS configuration from settings - uses Application Default Credentials + self.gcs_bucket_name = config.media_gcs_bucket_name + + # Future providers can be added here + # self.aws_bucket_name = config.aws_bucket_name + # self.azure_container_name = config.azure_container_name + + +class CloudStorageHandler: + """Generic cloud storage handler that can work with multiple providers.""" + + def __init__(self, config: CloudStorageConfig): + self.config = config + self._async_gcs_client = None + self._sync_gcs_client = None # Only for signed URLs + + def _get_async_gcs_client(self): + """Lazy initialization of async GCS client.""" + if self._async_gcs_client is None: + # Use Application Default Credentials (ADC) + self._async_gcs_client = async_gcs_storage.Storage() + return self._async_gcs_client + + async def close(self): + """Close all client connections properly.""" + if self._async_gcs_client is not None: + await self._async_gcs_client.close() + self._async_gcs_client = None + + async def __aenter__(self): + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.close() + + def _get_sync_gcs_client(self): + """Lazy initialization of sync GCS client (only for signed URLs).""" + if self._sync_gcs_client is None: + # Use Application Default Credentials (ADC) - same as media.py + self._sync_gcs_client = gcs_storage.Client() + return self._sync_gcs_client + + def parse_cloud_path(self, path: str) -> Tuple[str, str]: + """ + Parse a cloud storage path and return provider and actual path. + + Args: + path: Cloud storage path (e.g., "gcs://bucket/path/to/file") + + Returns: + Tuple of (provider, actual_path) + """ + if path.startswith("gcs://"): + return "gcs", path[6:] # Remove "gcs://" prefix + # Future providers: + # elif path.startswith("s3://"): + # return "s3", path[5:] + # elif path.startswith("azure://"): + # return "azure", path[8:] + else: + raise ValueError(f"Unsupported cloud storage path: {path}") + + def is_cloud_path(self, path: str) -> bool: + """Check if a path is a cloud storage path.""" + return path.startswith(("gcs://", "s3://", "azure://")) + + async def store_file( + self, + content: bytes, + filename: str, + provider: str = "gcs", + expiration_hours: int = 48, + user_id: str | None = None, + graph_exec_id: str | None = None, + ) -> str: + """ + Store file content in cloud storage. + + Args: + content: File content as bytes + filename: Desired filename + provider: Cloud storage provider ("gcs", "s3", "azure") + expiration_hours: Hours until expiration (1-48, default: 48) + user_id: User ID for user-scoped files (optional) + graph_exec_id: Graph execution ID for execution-scoped files (optional) + + Note: + Provide either user_id OR graph_exec_id, not both. If neither is provided, + files will be stored as system uploads. + + Returns: + Cloud storage path (e.g., "gcs://bucket/path/to/file") + """ + if provider == "gcs": + return await self._store_file_gcs( + content, filename, expiration_hours, user_id, graph_exec_id + ) + else: + raise ValueError(f"Unsupported cloud storage provider: {provider}") + + async def _store_file_gcs( + self, + content: bytes, + filename: str, + expiration_hours: int, + user_id: str | None = None, + graph_exec_id: str | None = None, + ) -> str: + """Store file in Google Cloud Storage.""" + if not self.config.gcs_bucket_name: + raise ValueError("GCS_BUCKET_NAME not configured") + + # Validate that only one scope is provided + if user_id and graph_exec_id: + raise ValueError("Provide either user_id OR graph_exec_id, not both") + + async_client = self._get_async_gcs_client() + + # Generate unique path with appropriate scope + unique_id = str(uuid.uuid4()) + if user_id: + # User-scoped uploads + blob_name = f"uploads/users/{user_id}/{unique_id}/{filename}" + elif graph_exec_id: + # Execution-scoped uploads + blob_name = f"uploads/executions/{graph_exec_id}/{unique_id}/{filename}" + else: + # System uploads (for backwards compatibility) + blob_name = f"uploads/system/{unique_id}/{filename}" + + # Upload content with metadata using pure async client + upload_time = datetime.now(timezone.utc) + expiration_time = upload_time + timedelta(hours=expiration_hours) + + await async_client.upload( + self.config.gcs_bucket_name, + blob_name, + content, + metadata={ + "uploaded_at": upload_time.isoformat(), + "expires_at": expiration_time.isoformat(), + "expiration_hours": str(expiration_hours), + }, + ) + + return f"gcs://{self.config.gcs_bucket_name}/{blob_name}" + + async def retrieve_file( + self, + cloud_path: str, + user_id: str | None = None, + graph_exec_id: str | None = None, + ) -> bytes: + """ + Retrieve file content from cloud storage. + + Args: + cloud_path: Cloud storage path (e.g., "gcs://bucket/path/to/file") + user_id: User ID for authorization of user-scoped files (optional) + graph_exec_id: Graph execution ID for authorization of execution-scoped files (optional) + + Returns: + File content as bytes + + Raises: + PermissionError: If user tries to access files they don't own + """ + provider, path = self.parse_cloud_path(cloud_path) + + if provider == "gcs": + return await self._retrieve_file_gcs(path, user_id, graph_exec_id) + else: + raise ValueError(f"Unsupported cloud storage provider: {provider}") + + async def _retrieve_file_gcs( + self, path: str, user_id: str | None = None, graph_exec_id: str | None = None + ) -> bytes: + """Retrieve file from Google Cloud Storage with authorization.""" + # Parse bucket and blob name from path + parts = path.split("/", 1) + if len(parts) != 2: + raise ValueError(f"Invalid GCS path: {path}") + + bucket_name, blob_name = parts + + # Authorization check + self._validate_file_access(blob_name, user_id, graph_exec_id) + + async_client = self._get_async_gcs_client() + + try: + # Download content using pure async client + content = await async_client.download(bucket_name, blob_name) + return content + except Exception as e: + # Convert gcloud-aio exceptions to standard ones + if "404" in str(e) or "Not Found" in str(e): + raise FileNotFoundError(f"File not found: gcs://{path}") + raise + + def _validate_file_access( + self, + blob_name: str, + user_id: str | None = None, + graph_exec_id: str | None = None, + ) -> None: + """ + Validate that a user can access a specific file path. + + Args: + blob_name: The blob path in GCS + user_id: The requesting user ID (optional) + graph_exec_id: The requesting graph execution ID (optional) + + Raises: + PermissionError: If access is denied + """ + + # Normalize the path to prevent path traversal attacks + normalized_path = os.path.normpath(blob_name) + + # Ensure the normalized path doesn't contain any path traversal attempts + if ".." in normalized_path or normalized_path.startswith("/"): + raise PermissionError("Invalid file path: path traversal detected") + + # Split into components and validate each part + path_parts = normalized_path.split("/") + + # Validate path structure: must start with "uploads/" + if not path_parts or path_parts[0] != "uploads": + raise PermissionError("Invalid file path: must be under uploads/") + + # System uploads (uploads/system/*) can be accessed by anyone for backwards compatibility + if len(path_parts) >= 2 and path_parts[1] == "system": + return + + # User-specific uploads (uploads/users/{user_id}/*) require matching user_id + if len(path_parts) >= 2 and path_parts[1] == "users": + if not user_id or len(path_parts) < 3: + raise PermissionError( + "User ID required to access user files" + if not user_id + else "Invalid user file path format" + ) + + file_owner_id = path_parts[2] + # Validate user_id format (basic validation) - no need to check ".." again since we already did + if not file_owner_id or "/" in file_owner_id: + raise PermissionError("Invalid user ID in path") + + if file_owner_id != user_id: + raise PermissionError( + f"Access denied: file belongs to user {file_owner_id}" + ) + return + + # Execution-specific uploads (uploads/executions/{graph_exec_id}/*) require matching graph_exec_id + if len(path_parts) >= 2 and path_parts[1] == "executions": + if not graph_exec_id or len(path_parts) < 3: + raise PermissionError( + "Graph execution ID required to access execution files" + if not graph_exec_id + else "Invalid execution file path format" + ) + + file_exec_id = path_parts[2] + # Validate execution_id format (basic validation) - no need to check ".." again since we already did + if not file_exec_id or "/" in file_exec_id: + raise PermissionError("Invalid execution ID in path") + + if file_exec_id != graph_exec_id: + raise PermissionError( + f"Access denied: file belongs to execution {file_exec_id}" + ) + return + + # Legacy uploads directory (uploads/*) - allow for backwards compatibility with warning + # Note: We already validated it starts with "uploads/" above, so this is guaranteed to match + logger.warning(f"Accessing legacy upload path: {blob_name}") + return + + async def generate_signed_url( + self, + cloud_path: str, + expiration_hours: int = 1, + user_id: str | None = None, + graph_exec_id: str | None = None, + ) -> str: + """ + Generate a signed URL for temporary access to a cloud storage file. + + Args: + cloud_path: Cloud storage path + expiration_hours: URL expiration in hours + user_id: User ID for authorization (required for user files) + graph_exec_id: Graph execution ID for authorization (required for execution files) + + Returns: + Signed URL string + + Raises: + PermissionError: If user tries to access files they don't own + """ + provider, path = self.parse_cloud_path(cloud_path) + + if provider == "gcs": + return await self._generate_signed_url_gcs( + path, expiration_hours, user_id, graph_exec_id + ) + else: + raise ValueError(f"Unsupported cloud storage provider: {provider}") + + async def _generate_signed_url_gcs( + self, + path: str, + expiration_hours: int, + user_id: str | None = None, + graph_exec_id: str | None = None, + ) -> str: + """Generate signed URL for GCS with authorization.""" + + # Parse bucket and blob name from path + parts = path.split("/", 1) + if len(parts) != 2: + raise ValueError(f"Invalid GCS path: {path}") + + bucket_name, blob_name = parts + + # Authorization check + self._validate_file_access(blob_name, user_id, graph_exec_id) + + # Use sync client for signed URLs since gcloud-aio doesn't support them + sync_client = self._get_sync_gcs_client() + bucket = sync_client.bucket(bucket_name) + blob = bucket.blob(blob_name) + + # Generate signed URL asynchronously using sync client + url = await asyncio.to_thread( + blob.generate_signed_url, + version="v4", + expiration=datetime.now(timezone.utc) + timedelta(hours=expiration_hours), + method="GET", + ) + + return url + + async def delete_expired_files(self, provider: str = "gcs") -> int: + """ + Delete files that have passed their expiration time. + + Args: + provider: Cloud storage provider + + Returns: + Number of files deleted + """ + if provider == "gcs": + return await self._delete_expired_files_gcs() + else: + raise ValueError(f"Unsupported cloud storage provider: {provider}") + + async def _delete_expired_files_gcs(self) -> int: + """Delete expired files from GCS based on metadata.""" + if not self.config.gcs_bucket_name: + raise ValueError("GCS_BUCKET_NAME not configured") + + async_client = self._get_async_gcs_client() + current_time = datetime.now(timezone.utc) + + try: + # List all blobs in the uploads directory using pure async client + list_response = await async_client.list_objects( + self.config.gcs_bucket_name, params={"prefix": "uploads/"} + ) + + items = list_response.get("items", []) + deleted_count = 0 + + # Process deletions in parallel with limited concurrency + semaphore = asyncio.Semaphore(10) # Limit to 10 concurrent deletions + + async def delete_if_expired(blob_info): + async with semaphore: + blob_name = blob_info.get("name", "") + try: + # Get blob metadata - need to fetch it separately + if not blob_name: + return 0 + + # Get metadata for this specific blob using pure async client + metadata_response = await async_client.download_metadata( + self.config.gcs_bucket_name, blob_name + ) + metadata = metadata_response.get("metadata", {}) + + if metadata and "expires_at" in metadata: + expires_at = datetime.fromisoformat(metadata["expires_at"]) + if current_time > expires_at: + # Delete using pure async client + await async_client.delete( + self.config.gcs_bucket_name, blob_name + ) + return 1 + except Exception as e: + # Log specific errors for debugging + logger.warning( + f"Failed to process file {blob_name} during cleanup: {e}" + ) + # Skip files with invalid metadata or delete errors + pass + return 0 + + if items: + results = await asyncio.gather( + *[delete_if_expired(blob) for blob in items] + ) + deleted_count = sum(results) + + return deleted_count + + except Exception as e: + # Log the error for debugging but continue operation + logger.error(f"Cleanup operation failed: {e}") + # Return 0 - we'll try again next cleanup cycle + return 0 + + async def check_file_expired(self, cloud_path: str) -> bool: + """ + Check if a file has expired based on its metadata. + + Args: + cloud_path: Cloud storage path + + Returns: + True if file has expired, False otherwise + """ + provider, path = self.parse_cloud_path(cloud_path) + + if provider == "gcs": + return await self._check_file_expired_gcs(path) + else: + raise ValueError(f"Unsupported cloud storage provider: {provider}") + + async def _check_file_expired_gcs(self, path: str) -> bool: + """Check if a GCS file has expired.""" + parts = path.split("/", 1) + if len(parts) != 2: + raise ValueError(f"Invalid GCS path: {path}") + + bucket_name, blob_name = parts + + async_client = self._get_async_gcs_client() + + try: + # Get object metadata using pure async client + metadata_info = await async_client.download_metadata(bucket_name, blob_name) + metadata = metadata_info.get("metadata", {}) + + if metadata and "expires_at" in metadata: + expires_at = datetime.fromisoformat(metadata["expires_at"]) + return datetime.now(timezone.utc) > expires_at + + except Exception as e: + # If file doesn't exist or we can't read metadata + if "404" in str(e) or "Not Found" in str(e): + logger.debug(f"File not found during expiration check: {blob_name}") + return True # File doesn't exist, consider it expired + + # Log other types of errors for debugging + logger.warning(f"Failed to check expiration for {blob_name}: {e}") + # If we can't read metadata for other reasons, assume not expired + return False + + return False + + +# Global instance with thread safety +_cloud_storage_handler = None +_handler_lock = asyncio.Lock() +_cleanup_lock = asyncio.Lock() + + +async def get_cloud_storage_handler() -> CloudStorageHandler: + """Get the global cloud storage handler instance with proper locking.""" + global _cloud_storage_handler + + if _cloud_storage_handler is None: + async with _handler_lock: + # Double-check pattern to avoid race conditions + if _cloud_storage_handler is None: + config = CloudStorageConfig() + _cloud_storage_handler = CloudStorageHandler(config) + + return _cloud_storage_handler + + +async def shutdown_cloud_storage_handler(): + """Properly shutdown the global cloud storage handler.""" + global _cloud_storage_handler + + if _cloud_storage_handler is not None: + async with _handler_lock: + if _cloud_storage_handler is not None: + await _cloud_storage_handler.close() + _cloud_storage_handler = None + + +async def cleanup_expired_files_async() -> int: + """ + Clean up expired files from cloud storage. + + This function uses a lock to prevent concurrent cleanup operations. + + Returns: + Number of files deleted + """ + # Use cleanup lock to prevent concurrent cleanup operations + async with _cleanup_lock: + try: + logger.info("Starting cleanup of expired cloud storage files") + handler = await get_cloud_storage_handler() + deleted_count = await handler.delete_expired_files() + logger.info(f"Cleaned up {deleted_count} expired files from cloud storage") + return deleted_count + except Exception as e: + logger.error(f"Error during cloud storage cleanup: {e}") + return 0 diff --git a/autogpt_platform/backend/backend/util/cloud_storage_test.py b/autogpt_platform/backend/backend/util/cloud_storage_test.py new file mode 100644 index 000000000000..4dd0d79d20be --- /dev/null +++ b/autogpt_platform/backend/backend/util/cloud_storage_test.py @@ -0,0 +1,472 @@ +""" +Tests for cloud storage utilities. +""" + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.util.cloud_storage import CloudStorageConfig, CloudStorageHandler + + +class TestCloudStorageHandler: + """Test cases for CloudStorageHandler.""" + + @pytest.fixture + def config(self): + """Create a test configuration.""" + config = CloudStorageConfig() + config.gcs_bucket_name = "test-bucket" + return config + + @pytest.fixture + def handler(self, config): + """Create a test handler.""" + return CloudStorageHandler(config) + + def test_parse_cloud_path_gcs(self, handler): + """Test parsing GCS paths.""" + provider, path = handler.parse_cloud_path("gcs://bucket/path/to/file.txt") + assert provider == "gcs" + assert path == "bucket/path/to/file.txt" + + def test_parse_cloud_path_invalid(self, handler): + """Test parsing invalid cloud paths.""" + with pytest.raises(ValueError, match="Unsupported cloud storage path"): + handler.parse_cloud_path("invalid://path") + + def test_is_cloud_path(self, handler): + """Test cloud path detection.""" + assert handler.is_cloud_path("gcs://bucket/file.txt") + assert handler.is_cloud_path("s3://bucket/file.txt") + assert handler.is_cloud_path("azure://container/file.txt") + assert not handler.is_cloud_path("http://example.com/file.txt") + assert not handler.is_cloud_path("/local/path/file.txt") + assert not handler.is_cloud_path("data:text/plain;base64,SGVsbG8=") + + @patch.object(CloudStorageHandler, "_get_async_gcs_client") + @pytest.mark.asyncio + async def test_store_file_gcs(self, mock_get_async_client, handler): + """Test storing file in GCS.""" + # Mock async GCS client + mock_async_client = AsyncMock() + mock_get_async_client.return_value = mock_async_client + + # Mock the upload method + mock_async_client.upload = AsyncMock() + + content = b"test file content" + filename = "test.txt" + + result = await handler.store_file(content, filename, "gcs", expiration_hours=24) + + # Verify the result format + assert result.startswith("gcs://test-bucket/uploads/") + assert result.endswith("/test.txt") + + # Verify upload was called with correct parameters + mock_async_client.upload.assert_called_once() + call_args = mock_async_client.upload.call_args + assert call_args[0][0] == "test-bucket" # bucket name + assert call_args[0][1].startswith("uploads/system/") # blob name + assert call_args[0][2] == content # file content + assert "metadata" in call_args[1] # metadata argument + + @patch.object(CloudStorageHandler, "_get_async_gcs_client") + @pytest.mark.asyncio + async def test_retrieve_file_gcs(self, mock_get_async_client, handler): + """Test retrieving file from GCS.""" + # Mock async GCS client + mock_async_client = AsyncMock() + mock_get_async_client.return_value = mock_async_client + + # Mock the download method + mock_async_client.download = AsyncMock(return_value=b"test content") + + result = await handler.retrieve_file( + "gcs://test-bucket/uploads/system/uuid123/file.txt" + ) + + assert result == b"test content" + mock_async_client.download.assert_called_once_with( + "test-bucket", "uploads/system/uuid123/file.txt" + ) + + @patch.object(CloudStorageHandler, "_get_async_gcs_client") + @pytest.mark.asyncio + async def test_retrieve_file_not_found(self, mock_get_async_client, handler): + """Test retrieving non-existent file from GCS.""" + # Mock async GCS client + mock_async_client = AsyncMock() + mock_get_async_client.return_value = mock_async_client + + # Mock the download method to raise a 404 exception + mock_async_client.download = AsyncMock(side_effect=Exception("404 Not Found")) + + with pytest.raises(FileNotFoundError): + await handler.retrieve_file( + "gcs://test-bucket/uploads/system/uuid123/nonexistent.txt" + ) + + @patch.object(CloudStorageHandler, "_get_sync_gcs_client") + @pytest.mark.asyncio + async def test_generate_signed_url_gcs(self, mock_get_sync_client, handler): + """Test generating signed URL for GCS.""" + # Mock sync GCS client for signed URLs + mock_sync_client = MagicMock() + mock_bucket = MagicMock() + mock_blob = MagicMock() + + mock_get_sync_client.return_value = mock_sync_client + mock_sync_client.bucket.return_value = mock_bucket + mock_bucket.blob.return_value = mock_blob + mock_blob.generate_signed_url.return_value = "https://signed-url.example.com" + + result = await handler.generate_signed_url( + "gcs://test-bucket/uploads/system/uuid123/file.txt", 1 + ) + + assert result == "https://signed-url.example.com" + mock_blob.generate_signed_url.assert_called_once() + + @pytest.mark.asyncio + async def test_unsupported_provider(self, handler): + """Test unsupported provider error.""" + with pytest.raises(ValueError, match="Unsupported cloud storage provider"): + await handler.store_file(b"content", "file.txt", "unsupported") + + with pytest.raises(ValueError, match="Unsupported cloud storage path"): + await handler.retrieve_file("unsupported://bucket/file.txt") + + with pytest.raises(ValueError, match="Unsupported cloud storage path"): + await handler.generate_signed_url("unsupported://bucket/file.txt") + + @patch.object(CloudStorageHandler, "_get_async_gcs_client") + @pytest.mark.asyncio + async def test_delete_expired_files_gcs(self, mock_get_async_client, handler): + """Test deleting expired files from GCS.""" + from datetime import datetime, timedelta, timezone + + # Mock async GCS client + mock_async_client = AsyncMock() + mock_get_async_client.return_value = mock_async_client + + # Mock list_objects response with expired and valid files + expired_time = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + valid_time = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() + + mock_list_response = { + "items": [ + {"name": "uploads/expired-file.txt"}, + {"name": "uploads/valid-file.txt"}, + ] + } + mock_async_client.list_objects = AsyncMock(return_value=mock_list_response) + + # Mock download_metadata responses + async def mock_download_metadata(bucket, blob_name): + if "expired-file" in blob_name: + return {"metadata": {"expires_at": expired_time}} + else: + return {"metadata": {"expires_at": valid_time}} + + mock_async_client.download_metadata = AsyncMock( + side_effect=mock_download_metadata + ) + mock_async_client.delete = AsyncMock() + + result = await handler.delete_expired_files("gcs") + + assert result == 1 # Only one file should be deleted + # Verify delete was called once (for expired file) + assert mock_async_client.delete.call_count == 1 + + @patch.object(CloudStorageHandler, "_get_async_gcs_client") + @pytest.mark.asyncio + async def test_check_file_expired_gcs(self, mock_get_async_client, handler): + """Test checking if a file has expired.""" + from datetime import datetime, timedelta, timezone + + # Mock async GCS client + mock_async_client = AsyncMock() + mock_get_async_client.return_value = mock_async_client + + # Test with expired file + expired_time = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + mock_async_client.download_metadata = AsyncMock( + return_value={"metadata": {"expires_at": expired_time}} + ) + + result = await handler.check_file_expired("gcs://test-bucket/expired-file.txt") + assert result is True + + # Test with valid file + valid_time = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() + mock_async_client.download_metadata = AsyncMock( + return_value={"metadata": {"expires_at": valid_time}} + ) + + result = await handler.check_file_expired("gcs://test-bucket/valid-file.txt") + assert result is False + + @patch("backend.util.cloud_storage.get_cloud_storage_handler") + @pytest.mark.asyncio + async def test_cleanup_expired_files_async(self, mock_get_handler): + """Test the async cleanup function.""" + from backend.util.cloud_storage import cleanup_expired_files_async + + # Mock the handler + mock_handler = mock_get_handler.return_value + mock_handler.delete_expired_files = AsyncMock(return_value=3) + + result = await cleanup_expired_files_async() + + assert result == 3 + mock_get_handler.assert_called_once() + mock_handler.delete_expired_files.assert_called_once() + + @patch("backend.util.cloud_storage.get_cloud_storage_handler") + @pytest.mark.asyncio + async def test_cleanup_expired_files_async_error(self, mock_get_handler): + """Test the async cleanup function with error.""" + from backend.util.cloud_storage import cleanup_expired_files_async + + # Mock the handler to raise an exception + mock_handler = mock_get_handler.return_value + mock_handler.delete_expired_files = AsyncMock( + side_effect=Exception("GCS error") + ) + + result = await cleanup_expired_files_async() + + assert result == 0 # Should return 0 on error + mock_get_handler.assert_called_once() + mock_handler.delete_expired_files.assert_called_once() + + def test_validate_file_access_system_files(self, handler): + """Test access validation for system files.""" + # System files should be accessible by anyone + handler._validate_file_access("uploads/system/uuid123/file.txt", None) + handler._validate_file_access("uploads/system/uuid123/file.txt", "user123") + + def test_validate_file_access_user_files_success(self, handler): + """Test successful access validation for user files.""" + # User should be able to access their own files + handler._validate_file_access( + "uploads/users/user123/uuid456/file.txt", "user123" + ) + + def test_validate_file_access_user_files_no_user_id(self, handler): + """Test access validation failure when no user_id provided for user files.""" + with pytest.raises( + PermissionError, match="User ID required to access user files" + ): + handler._validate_file_access( + "uploads/users/user123/uuid456/file.txt", None + ) + + def test_validate_file_access_user_files_wrong_user(self, handler): + """Test access validation failure when accessing another user's files.""" + with pytest.raises( + PermissionError, match="Access denied: file belongs to user user123" + ): + handler._validate_file_access( + "uploads/users/user123/uuid456/file.txt", "user456" + ) + + def test_validate_file_access_legacy_files(self, handler): + """Test access validation for legacy files.""" + # Legacy files should be accessible with a warning + handler._validate_file_access("uploads/uuid789/file.txt", None) + handler._validate_file_access("uploads/uuid789/file.txt", "user123") + + def test_validate_file_access_invalid_path(self, handler): + """Test access validation failure for invalid paths.""" + with pytest.raises( + PermissionError, match="Invalid file path: must be under uploads/" + ): + handler._validate_file_access("invalid/path/file.txt", "user123") + + @patch.object(CloudStorageHandler, "_get_async_gcs_client") + @pytest.mark.asyncio + async def test_retrieve_file_with_authorization(self, mock_get_client, handler): + """Test file retrieval with authorization.""" + # Mock async GCS client + mock_client = AsyncMock() + mock_get_client.return_value = mock_client + mock_client.download = AsyncMock(return_value=b"test content") + + # Test successful retrieval of user's own file + result = await handler.retrieve_file( + "gcs://test-bucket/uploads/users/user123/uuid456/file.txt", + user_id="user123", + ) + assert result == b"test content" + mock_client.download.assert_called_once_with( + "test-bucket", "uploads/users/user123/uuid456/file.txt" + ) + + # Test authorization failure + with pytest.raises(PermissionError): + await handler.retrieve_file( + "gcs://test-bucket/uploads/users/user123/uuid456/file.txt", + user_id="user456", + ) + + @patch.object(CloudStorageHandler, "_get_async_gcs_client") + @pytest.mark.asyncio + async def test_store_file_with_user_id(self, mock_get_client, handler): + """Test file storage with user ID.""" + # Mock async GCS client + mock_client = AsyncMock() + mock_get_client.return_value = mock_client + mock_client.upload = AsyncMock() + + content = b"test file content" + filename = "test.txt" + + # Test with user_id + result = await handler.store_file( + content, filename, "gcs", expiration_hours=24, user_id="user123" + ) + + # Verify the result format includes user path + assert result.startswith("gcs://test-bucket/uploads/users/user123/") + assert result.endswith("/test.txt") + mock_client.upload.assert_called() + + # Test without user_id (system upload) + result = await handler.store_file( + content, filename, "gcs", expiration_hours=24, user_id=None + ) + + # Verify the result format includes system path + assert result.startswith("gcs://test-bucket/uploads/system/") + assert result.endswith("/test.txt") + assert mock_client.upload.call_count == 2 + + @patch.object(CloudStorageHandler, "_get_async_gcs_client") + @pytest.mark.asyncio + async def test_store_file_with_graph_exec_id(self, mock_get_async_client, handler): + """Test file storage with graph execution ID.""" + # Mock async GCS client + mock_async_client = AsyncMock() + mock_get_async_client.return_value = mock_async_client + + # Mock the upload method + mock_async_client.upload = AsyncMock() + + content = b"test file content" + filename = "test.txt" + + # Test with graph_exec_id + result = await handler.store_file( + content, filename, "gcs", expiration_hours=24, graph_exec_id="exec123" + ) + + # Verify the result format includes execution path + assert result.startswith("gcs://test-bucket/uploads/executions/exec123/") + assert result.endswith("/test.txt") + + @pytest.mark.asyncio + async def test_store_file_with_both_user_and_exec_id(self, handler): + """Test file storage fails when both user_id and graph_exec_id are provided.""" + content = b"test file content" + filename = "test.txt" + + with pytest.raises( + ValueError, match="Provide either user_id OR graph_exec_id, not both" + ): + await handler.store_file( + content, + filename, + "gcs", + expiration_hours=24, + user_id="user123", + graph_exec_id="exec123", + ) + + def test_validate_file_access_execution_files_success(self, handler): + """Test successful access validation for execution files.""" + # Graph execution should be able to access their own files + handler._validate_file_access( + "uploads/executions/exec123/uuid456/file.txt", graph_exec_id="exec123" + ) + + def test_validate_file_access_execution_files_no_exec_id(self, handler): + """Test access validation failure when no graph_exec_id provided for execution files.""" + with pytest.raises( + PermissionError, + match="Graph execution ID required to access execution files", + ): + handler._validate_file_access( + "uploads/executions/exec123/uuid456/file.txt", user_id="user123" + ) + + def test_validate_file_access_execution_files_wrong_exec_id(self, handler): + """Test access validation failure when accessing another execution's files.""" + with pytest.raises( + PermissionError, match="Access denied: file belongs to execution exec123" + ): + handler._validate_file_access( + "uploads/executions/exec123/uuid456/file.txt", graph_exec_id="exec456" + ) + + @patch.object(CloudStorageHandler, "_get_async_gcs_client") + @pytest.mark.asyncio + async def test_retrieve_file_with_exec_authorization( + self, mock_get_async_client, handler + ): + """Test file retrieval with execution authorization.""" + # Mock async GCS client + mock_async_client = AsyncMock() + mock_get_async_client.return_value = mock_async_client + + # Mock the download method + mock_async_client.download = AsyncMock(return_value=b"test content") + + # Test successful retrieval of execution's own file + result = await handler.retrieve_file( + "gcs://test-bucket/uploads/executions/exec123/uuid456/file.txt", + graph_exec_id="exec123", + ) + assert result == b"test content" + + # Test authorization failure + with pytest.raises(PermissionError): + await handler.retrieve_file( + "gcs://test-bucket/uploads/executions/exec123/uuid456/file.txt", + graph_exec_id="exec456", + ) + + @patch.object(CloudStorageHandler, "_get_sync_gcs_client") + @pytest.mark.asyncio + async def test_generate_signed_url_with_exec_authorization( + self, mock_get_sync_client, handler + ): + """Test signed URL generation with execution authorization.""" + # Mock sync GCS client for signed URLs + mock_sync_client = MagicMock() + mock_bucket = MagicMock() + mock_blob = MagicMock() + + mock_get_sync_client.return_value = mock_sync_client + mock_sync_client.bucket.return_value = mock_bucket + mock_bucket.blob.return_value = mock_blob + mock_blob.generate_signed_url.return_value = "https://signed-url.example.com" + + # Test successful signed URL generation for execution's own file + result = await handler.generate_signed_url( + "gcs://test-bucket/uploads/executions/exec123/uuid456/file.txt", + 1, + graph_exec_id="exec123", + ) + assert result == "https://signed-url.example.com" + + # Test authorization failure + with pytest.raises(PermissionError): + await handler.generate_signed_url( + "gcs://test-bucket/uploads/executions/exec123/uuid456/file.txt", + 1, + graph_exec_id="exec456", + ) diff --git a/autogpt_platform/backend/backend/util/decorator.py b/autogpt_platform/backend/backend/util/decorator.py index 9047ea0b77ec..3767435646d2 100644 --- a/autogpt_platform/backend/backend/util/decorator.py +++ b/autogpt_platform/backend/backend/util/decorator.py @@ -2,10 +2,22 @@ import logging import os import time -from typing import Callable, ParamSpec, Tuple, TypeVar +from typing import ( + Any, + Awaitable, + Callable, + Coroutine, + Literal, + ParamSpec, + Tuple, + TypeVar, + overload, +) from pydantic import BaseModel +from backend.util.logging import TruncatedLogger + class TimingInfo(BaseModel): cpu_time: float @@ -27,19 +39,25 @@ def _end_measurement( P = ParamSpec("P") T = TypeVar("T") -logger = logging.getLogger(__name__) +logger = TruncatedLogger(logging.getLogger(__name__)) -def time_measured(func: Callable[P, T]) -> Callable[P, Tuple[TimingInfo, T]]: +def time_measured( + func: Callable[P, T], +) -> Callable[P, Tuple[TimingInfo, T | BaseException]]: """ - Decorator to measure the time taken by a function to execute. + Decorator to measure the time taken by a synchronous function to execute. """ @functools.wraps(func) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> Tuple[TimingInfo, T]: + def wrapper( + *args: P.args, **kwargs: P.kwargs + ) -> Tuple[TimingInfo, T | BaseException]: start_wall_time, start_cpu_time = _start_measurement() try: result = func(*args, **kwargs) + except BaseException as e: + result = e finally: wall_duration, cpu_duration = _end_measurement( start_wall_time, start_cpu_time @@ -50,18 +68,141 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Tuple[TimingInfo, T]: return wrapper -def error_logged(func: Callable[P, T]) -> Callable[P, T | None]: +def async_time_measured( + func: Callable[P, Awaitable[T]], +) -> Callable[P, Awaitable[Tuple[TimingInfo, T | BaseException]]]: """ - Decorator to suppress and log any exceptions raised by a function. + Decorator to measure the time taken by an async function to execute. """ @functools.wraps(func) - def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | None: + async def async_wrapper( + *args: P.args, **kwargs: P.kwargs + ) -> Tuple[TimingInfo, T | BaseException]: + start_wall_time, start_cpu_time = _start_measurement() try: - return func(*args, **kwargs) - except Exception as e: - logger.exception( - f"Error when calling function {func.__name__} with arguments {args} {kwargs}: {e}" + result = await func(*args, **kwargs) + except BaseException as e: + result = e + finally: + wall_duration, cpu_duration = _end_measurement( + start_wall_time, start_cpu_time ) + timing_info = TimingInfo(cpu_time=cpu_duration, wall_time=wall_duration) + return timing_info, result - return wrapper + return async_wrapper + + +@overload +def error_logged( + *, swallow: Literal[True] +) -> Callable[[Callable[P, T]], Callable[P, T | None]]: ... + + +@overload +def error_logged( + *, swallow: Literal[False] +) -> Callable[[Callable[P, T]], Callable[P, T]]: ... + + +@overload +def error_logged() -> Callable[[Callable[P, T]], Callable[P, T | None]]: ... + + +def error_logged( + *, swallow: bool = True +) -> ( + Callable[[Callable[P, T]], Callable[P, T | None]] + | Callable[[Callable[P, T]], Callable[P, T]] +): + """ + Decorator to log any exceptions raised by a function, with optional suppression. + + Args: + swallow: Whether to suppress the exception (True) or re-raise it (False). Default is True. + + Usage: + @error_logged() # Default behavior (swallow errors) + @error_logged(swallow=False) # Log and re-raise errors + """ + + def decorator(f: Callable[P, T]) -> Callable[P, T | None]: + @functools.wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | None: + try: + return f(*args, **kwargs) + except BaseException as e: + logger.exception( + f"Error when calling function {f.__name__} with arguments {args} {kwargs}: {e}" + ) + if not swallow: + raise + return None + + return wrapper + + return decorator + + +@overload +def async_error_logged( + *, swallow: Literal[True] +) -> Callable[ + [Callable[P, Coroutine[Any, Any, T]]], Callable[P, Coroutine[Any, Any, T | None]] +]: ... + + +@overload +def async_error_logged( + *, swallow: Literal[False] +) -> Callable[ + [Callable[P, Coroutine[Any, Any, T]]], Callable[P, Coroutine[Any, Any, T]] +]: ... + + +@overload +def async_error_logged() -> Callable[ + [Callable[P, Coroutine[Any, Any, T]]], + Callable[P, Coroutine[Any, Any, T | None]], +]: ... + + +def async_error_logged(*, swallow: bool = True) -> ( + Callable[ + [Callable[P, Coroutine[Any, Any, T]]], + Callable[P, Coroutine[Any, Any, T | None]], + ] + | Callable[ + [Callable[P, Coroutine[Any, Any, T]]], Callable[P, Coroutine[Any, Any, T]] + ] +): + """ + Decorator to log any exceptions raised by an async function, with optional suppression. + + Args: + swallow: Whether to suppress the exception (True) or re-raise it (False). Default is True. + + Usage: + @async_error_logged() # Default behavior (swallow errors) + @async_error_logged(swallow=False) # Log and re-raise errors + """ + + def decorator( + f: Callable[P, Coroutine[Any, Any, T]], + ) -> Callable[P, Coroutine[Any, Any, T | None]]: + @functools.wraps(f) + async def wrapper(*args: P.args, **kwargs: P.kwargs) -> T | None: + try: + return await f(*args, **kwargs) + except BaseException as e: + logger.exception( + f"Error when calling async function {f.__name__} with arguments {args} {kwargs}: {e}" + ) + if not swallow: + raise + return None + + return wrapper + + return decorator diff --git a/autogpt_platform/backend/backend/util/decorator_test.py b/autogpt_platform/backend/backend/util/decorator_test.py new file mode 100644 index 000000000000..07598c59bcaa --- /dev/null +++ b/autogpt_platform/backend/backend/util/decorator_test.py @@ -0,0 +1,98 @@ +import time + +import pytest + +from backend.util.decorator import async_error_logged, error_logged, time_measured +from backend.util.retry import continuous_retry + + +@time_measured +def example_function(a: int, b: int, c: int) -> int: + time.sleep(0.5) + return a + b + c + + +@error_logged(swallow=True) +def example_function_with_error_swallowed(a: int, b: int, c: int) -> int: + raise ValueError("This error should be swallowed") + + +@error_logged(swallow=False) +def example_function_with_error_not_swallowed(a: int, b: int, c: int) -> int: + raise ValueError("This error should NOT be swallowed") + + +@async_error_logged(swallow=True) +async def async_function_with_error_swallowed() -> int: + raise ValueError("This async error should be swallowed") + + +@async_error_logged(swallow=False) +async def async_function_with_error_not_swallowed() -> int: + raise ValueError("This async error should NOT be swallowed") + + +def test_timer_decorator(): + """Test that the time_measured decorator correctly measures execution time.""" + info, res = example_function(1, 2, 3) + assert info.cpu_time >= 0 + assert info.wall_time >= 0.4 + assert res == 6 + + +def test_error_decorator_swallow_true(): + """Test that error_logged(swallow=True) logs and swallows errors.""" + res = example_function_with_error_swallowed(1, 2, 3) + assert res is None + + +def test_error_decorator_swallow_false(): + """Test that error_logged(swallow=False) logs errors but re-raises them.""" + with pytest.raises(ValueError, match="This error should NOT be swallowed"): + example_function_with_error_not_swallowed(1, 2, 3) + + +def test_async_error_decorator_swallow_true(): + """Test that async_error_logged(swallow=True) logs and swallows errors.""" + import asyncio + + async def run_test(): + res = await async_function_with_error_swallowed() + return res + + res = asyncio.run(run_test()) + assert res is None + + +def test_async_error_decorator_swallow_false(): + """Test that async_error_logged(swallow=False) logs errors but re-raises them.""" + import asyncio + + async def run_test(): + await async_function_with_error_not_swallowed() + + with pytest.raises(ValueError, match="This async error should NOT be swallowed"): + asyncio.run(run_test()) + + +def test_continuous_retry_basic(): + """Test that continuous_retry decorator retries on exception.""" + + class MockManager: + def __init__(self): + self.call_count = 0 + + @continuous_retry(retry_delay=0.01) + def failing_method(self): + self.call_count += 1 + if self.call_count <= 2: + # Fail on first two calls + raise RuntimeError("Simulated failure") + return "success" + + mock_manager = MockManager() + + # Should retry and eventually succeed + result = mock_manager.failing_method() + assert result == "success" + assert mock_manager.call_count == 3 # Failed twice, succeeded on third diff --git a/autogpt_platform/backend/backend/util/encryption.py b/autogpt_platform/backend/backend/util/encryption.py index c8ba8b5edc50..c86abb6d74bb 100644 --- a/autogpt_platform/backend/backend/util/encryption.py +++ b/autogpt_platform/backend/backend/util/encryption.py @@ -30,5 +30,8 @@ def decrypt(self, encrypted_str: str) -> dict: """Decrypt string to dictionary""" if not encrypted_str: return {} - decrypted = self.fernet.decrypt(encrypted_str.encode()) - return json.loads(decrypted.decode()) + try: + decrypted = self.fernet.decrypt(encrypted_str.encode()) + return json.loads(decrypted.decode()) + except Exception: + return {} diff --git a/autogpt_platform/backend/backend/util/exceptions.py b/autogpt_platform/backend/backend/util/exceptions.py index 4bb3a08d9548..3a1efa0e5d85 100644 --- a/autogpt_platform/backend/backend/util/exceptions.py +++ b/autogpt_platform/backend/backend/util/exceptions.py @@ -2,5 +2,84 @@ class MissingConfigError(Exception): """The attempted operation requires configuration which is not available""" +class NotFoundError(ValueError): + """The requested record was not found, resulting in an error condition""" + + class NeedConfirmation(Exception): """The user must explicitly confirm that they want to proceed""" + + +class NotAuthorizedError(ValueError): + """The user is not authorized to perform the requested operation""" + + +class InsufficientBalanceError(ValueError): + user_id: str + message: str + balance: float + amount: float + + def __init__(self, message: str, user_id: str, balance: float, amount: float): + super().__init__(message) + self.args = (message, user_id, balance, amount) + self.message = message + self.user_id = user_id + self.balance = balance + self.amount = amount + + def __str__(self): + """Used to display the error message in the frontend, because we str() the error when sending the execution update""" + return self.message + + +class ModerationError(ValueError): + """Content moderation failure during execution""" + + user_id: str + message: str + graph_exec_id: str + moderation_type: str + content_id: str | None + + def __init__( + self, + message: str, + user_id: str, + graph_exec_id: str, + moderation_type: str = "content", + content_id: str | None = None, + ): + super().__init__(message) + self.args = (message, user_id, graph_exec_id, moderation_type, content_id) + self.message = message + self.user_id = user_id + self.graph_exec_id = graph_exec_id + self.moderation_type = moderation_type + self.content_id = content_id + + def __str__(self): + """Used to display the error message in the frontend, because we str() the error when sending the execution update""" + if self.content_id: + return f"{self.message} (Moderation ID: {self.content_id})" + return self.message + + +class GraphValidationError(ValueError): + """Structured validation error for graph validation failures""" + + def __init__( + self, message: str, node_errors: dict[str, dict[str, str]] | None = None + ): + super().__init__(message) + self.message = message + self.node_errors = node_errors or {} + + def __str__(self): + return self.message + "".join( + [ + f"\n {node_id}:" + + "".join([f"\n {k}: {e}" for k, e in errors.items()]) + for node_id, errors in self.node_errors.items() + ] + ) diff --git a/autogpt_platform/backend/backend/util/feature_flag.py b/autogpt_platform/backend/backend/util/feature_flag.py new file mode 100644 index 000000000000..9af04534793b --- /dev/null +++ b/autogpt_platform/backend/backend/util/feature_flag.py @@ -0,0 +1,257 @@ +import contextlib +import logging +from enum import Enum +from functools import wraps +from typing import Any, Awaitable, Callable, TypeVar + +import ldclient +from autogpt_libs.utils.cache import async_ttl_cache +from fastapi import HTTPException +from ldclient import Context, LDClient +from ldclient.config import Config +from typing_extensions import ParamSpec + +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) + +# Load settings at module level +settings = Settings() + +P = ParamSpec("P") +T = TypeVar("T") + +_is_initialized = False + + +class Flag(str, Enum): + """ + Centralized enum for all LaunchDarkly feature flags. + + Add new flags here to ensure consistency across the codebase. + """ + + AUTOMOD = "AutoMod" + AI_ACTIVITY_STATUS = "ai-agent-execution-summary" + BETA_BLOCKS = "beta-blocks" + AGENT_ACTIVITY = "agent-activity" + + +def get_client() -> LDClient: + """Get the LaunchDarkly client singleton.""" + if not _is_initialized: + initialize_launchdarkly() + return ldclient.get() + + +def initialize_launchdarkly() -> None: + sdk_key = settings.secrets.launch_darkly_sdk_key + logger.debug( + f"Initializing LaunchDarkly with SDK key: {'present' if sdk_key else 'missing'}" + ) + + if not sdk_key: + logger.warning("LaunchDarkly SDK key not configured") + return + + config = Config(sdk_key) + ldclient.set_config(config) + + if ldclient.get().is_initialized(): + global _is_initialized + _is_initialized = True + logger.info("LaunchDarkly client initialized successfully") + else: + logger.error("LaunchDarkly client failed to initialize") + + +def shutdown_launchdarkly() -> None: + """Shutdown the LaunchDarkly client.""" + if ldclient.get().is_initialized(): + ldclient.get().close() + logger.info("LaunchDarkly client closed successfully") + + +@async_ttl_cache(maxsize=1000, ttl_seconds=86400) # 1000 entries, 24 hours TTL +async def _fetch_user_context_data(user_id: str) -> Context: + """ + Fetch user context for LaunchDarkly from Supabase. + + Args: + user_id: The user ID to fetch data for + + Returns: + LaunchDarkly Context object + """ + builder = Context.builder(user_id).kind("user").anonymous(True) + + try: + from backend.util.clients import get_supabase + + # If we have user data, update context + response = get_supabase().auth.admin.get_user_by_id(user_id) + if response and response.user: + user = response.user + builder.anonymous(False) + if user.role: + builder.set("role", user.role) + # It's weird, I know, but it is what it is. + builder.set("custom", {"role": user.role}) + if user.email: + builder.set("email", user.email) + builder.set("email_domain", user.email.split("@")[-1]) + + except Exception as e: + logger.warning(f"Failed to fetch user context for {user_id}: {e}") + + return builder.build() + + +async def get_feature_flag_value( + flag_key: str, + user_id: str, + default: Any = None, +) -> Any: + """ + Get the raw value of a feature flag for a user. + + This is the generic function that returns the actual flag value, + which could be a boolean, string, number, or JSON object. + + Args: + flag_key: The LaunchDarkly feature flag key + user_id: The user ID to evaluate the flag for + default: Default value if LaunchDarkly is unavailable or flag evaluation fails + + Returns: + The flag value from LaunchDarkly + """ + try: + client = get_client() + + # Check if client is initialized + if not client.is_initialized(): + logger.debug( + f"LaunchDarkly not initialized, using default={default} for {flag_key}" + ) + return default + + # Get user context from Supabase + context = await _fetch_user_context_data(user_id) + + # Evaluate flag + result = client.variation(flag_key, context, default) + + logger.debug( + f"Feature flag {flag_key} for user {user_id}: {result} (type: {type(result).__name__})" + ) + return result + + except Exception as e: + logger.warning( + f"LaunchDarkly flag evaluation failed for {flag_key}: {e}, using default={default}" + ) + return default + + +async def is_feature_enabled( + flag_key: Flag, + user_id: str, + default: bool = False, +) -> bool: + """ + Check if a feature flag is enabled for a user. + + Args: + flag_key: The Flag enum value + user_id: The user ID to evaluate the flag for + default: Default value if LaunchDarkly is unavailable or flag evaluation fails + + Returns: + True if feature is enabled, False otherwise + """ + result = await get_feature_flag_value(flag_key.value, user_id, default) + + # If the result is already a boolean, return it + if isinstance(result, bool): + return result + + # Log a warning if the flag is not returning a boolean + logger.warning( + f"Feature flag {flag_key} returned non-boolean value: {result} (type: {type(result).__name__}). " + f"This flag should be configured as a boolean in LaunchDarkly. Using default={default}" + ) + + # Return the default if we get a non-boolean value + # This prevents objects from being incorrectly treated as True + return default + + +def feature_flag( + flag_key: str, + default: bool = False, +) -> Callable[[Callable[P, Awaitable[T]]], Callable[P, Awaitable[T]]]: + """ + Decorator for async feature flag protected endpoints. + + Args: + flag_key: The LaunchDarkly feature flag key + default: Default value if flag evaluation fails + + Returns: + Decorator that only works with async functions + """ + + def decorator(func: Callable[P, Awaitable[T]]) -> Callable[P, Awaitable[T]]: + @wraps(func) + async def async_wrapper(*args: P.args, **kwargs: P.kwargs) -> T: + try: + user_id = kwargs.get("user_id") + if not user_id: + raise ValueError("user_id is required") + + if not get_client().is_initialized(): + logger.warning( + f"LaunchDarkly not initialized, using default={default}" + ) + is_enabled = default + else: + # Use the internal function directly since we have a raw string flag_key + flag_value = await get_feature_flag_value( + flag_key, str(user_id), default + ) + # Ensure we treat flag value as boolean + if isinstance(flag_value, bool): + is_enabled = flag_value + else: + # Log warning and use default for non-boolean values + logger.warning( + f"Feature flag {flag_key} returned non-boolean value: {flag_value} (type: {type(flag_value).__name__}). " + f"Using default={default}" + ) + is_enabled = default + + if not is_enabled: + raise HTTPException(status_code=404, detail="Feature not available") + + return await func(*args, **kwargs) + except Exception as e: + logger.error(f"Error evaluating feature flag {flag_key}: {e}") + raise + + return async_wrapper + + return decorator + + +@contextlib.contextmanager +def mock_flag_variation(flag_key: str, return_value: Any): + """Context manager for testing feature flags.""" + original_variation = get_client().variation + get_client().variation = lambda key, context, default: ( + return_value if key == flag_key else original_variation(key, context, default) + ) + try: + yield + finally: + get_client().variation = original_variation diff --git a/autogpt_platform/backend/backend/util/feature_flag_test.py b/autogpt_platform/backend/backend/util/feature_flag_test.py new file mode 100644 index 000000000000..9bd99809ff29 --- /dev/null +++ b/autogpt_platform/backend/backend/util/feature_flag_test.py @@ -0,0 +1,113 @@ +import pytest +from fastapi import HTTPException +from ldclient import LDClient + +from backend.util.feature_flag import ( + Flag, + feature_flag, + is_feature_enabled, + mock_flag_variation, +) + + +@pytest.fixture +def ld_client(mocker): + client = mocker.Mock(spec=LDClient) + mocker.patch("ldclient.get", return_value=client) + client.is_initialized.return_value = True + return client + + +@pytest.mark.asyncio +async def test_feature_flag_enabled(ld_client): + ld_client.variation.return_value = True + + @feature_flag("test-flag") + async def test_function(user_id: str): + return "success" + + result = await test_function(user_id="test-user") + assert result == "success" + ld_client.variation.assert_called_once() + + +@pytest.mark.asyncio +async def test_feature_flag_unauthorized_response(ld_client): + ld_client.variation.return_value = False + + @feature_flag("test-flag") + async def test_function(user_id: str): + return "success" + + with pytest.raises(HTTPException) as exc_info: + await test_function(user_id="test-user") + assert exc_info.value.status_code == 404 + + +def test_mock_flag_variation(ld_client): + with mock_flag_variation("test-flag", True): + assert ld_client.variation("test-flag", None, False) is True + + with mock_flag_variation("test-flag", False): + assert ld_client.variation("test-flag", None, True) is False + + +@pytest.mark.asyncio +async def test_is_feature_enabled(ld_client): + """Test the is_feature_enabled helper function.""" + ld_client.is_initialized.return_value = True + ld_client.variation.return_value = True + + result = await is_feature_enabled(Flag.AUTOMOD, "user123", default=False) + assert result is True + + ld_client.variation.assert_called_once() + call_args = ld_client.variation.call_args + assert call_args[0][0] == "AutoMod" # flag_key + assert call_args[0][2] is False # default value + + +@pytest.mark.asyncio +async def test_is_feature_enabled_not_initialized(ld_client): + """Test is_feature_enabled when LaunchDarkly is not initialized.""" + ld_client.is_initialized.return_value = False + + result = await is_feature_enabled(Flag.AGENT_ACTIVITY, "user123", default=True) + assert result is True # Should return default + + ld_client.variation.assert_not_called() + + +@pytest.mark.asyncio +async def test_is_feature_enabled_exception(mocker): + """Test is_feature_enabled when get_client() raises an exception.""" + mocker.patch( + "backend.util.feature_flag.get_client", + side_effect=Exception("Client error"), + ) + + result = await is_feature_enabled(Flag.AGENT_ACTIVITY, "user123", default=True) + assert result is True # Should return default + + +def test_flag_enum_values(): + """Test that Flag enum has expected values.""" + assert Flag.AUTOMOD == "AutoMod" + assert Flag.AI_ACTIVITY_STATUS == "ai-agent-execution-summary" + assert Flag.BETA_BLOCKS == "beta-blocks" + assert Flag.AGENT_ACTIVITY == "agent-activity" + + +@pytest.mark.asyncio +async def test_is_feature_enabled_with_flag_enum(mocker): + """Test is_feature_enabled function with Flag enum.""" + mock_get_feature_flag_value = mocker.patch( + "backend.util.feature_flag.get_feature_flag_value" + ) + mock_get_feature_flag_value.return_value = True + + result = await is_feature_enabled(Flag.AUTOMOD, "user123") + + assert result is True + # Should call with the flag's string value + mock_get_feature_flag_value.assert_called_once() diff --git a/autogpt_platform/backend/backend/util/file.py b/autogpt_platform/backend/backend/util/file.py new file mode 100644 index 000000000000..1fc52f9e6ee4 --- /dev/null +++ b/autogpt_platform/backend/backend/util/file.py @@ -0,0 +1,177 @@ +import base64 +import mimetypes +import re +import shutil +import tempfile +import uuid +from pathlib import Path +from urllib.parse import urlparse + +from backend.util.cloud_storage import get_cloud_storage_handler +from backend.util.request import Requests +from backend.util.type import MediaFileType +from backend.util.virus_scanner import scan_content_safe + +TEMP_DIR = Path(tempfile.gettempdir()).resolve() + + +def get_exec_file_path(graph_exec_id: str, path: str) -> str: + """ + Utility to build an absolute path in the {temp}/exec_file/{exec_id}/... folder. + """ + return str(TEMP_DIR / "exec_file" / graph_exec_id / path) + + +def clean_exec_files(graph_exec_id: str, file: str = "") -> None: + """ + Utility to remove the {temp}/exec_file/{exec_id} folder and its contents. + """ + exec_path = Path(get_exec_file_path(graph_exec_id, file)) + if exec_path.exists() and exec_path.is_dir(): + shutil.rmtree(exec_path) + + +async def store_media_file( + graph_exec_id: str, + file: MediaFileType, + user_id: str, + return_content: bool = False, +) -> MediaFileType: + """ + Safely handle 'file' (a data URI, a URL, or a local path relative to {temp}/exec_file/{exec_id}), + placing or verifying it under: + {tempdir}/exec_file/{exec_id}/... + + If 'return_content=True', return a data URI (data:;base64,). + Otherwise, returns the file media path relative to the exec_id folder. + + For each MediaFileType type: + - Data URI: + -> decode and store in a new random file in that folder + - URL: + -> download and store in that folder + - Local path: + -> interpret as relative to that folder; verify it exists + (no copying, as it's presumably already there). + We realpath-check so no symlink or '..' can escape the folder. + + + :param graph_exec_id: The unique ID of the graph execution. + :param file: Data URI, URL, or local (relative) path. + :param return_content: If True, return a data URI of the file content. + If False, return the *relative* path inside the exec_id folder. + :return: The requested result: data URI or relative path of the media. + """ + # Build base path + base_path = Path(get_exec_file_path(graph_exec_id, "")) + base_path.mkdir(parents=True, exist_ok=True) + + # Helper functions + def _extension_from_mime(mime: str) -> str: + ext = mimetypes.guess_extension(mime, strict=False) + return ext if ext else ".bin" + + def _file_to_data_uri(path: Path) -> str: + mime_type = get_mime_type(str(path)) + b64 = base64.b64encode(path.read_bytes()).decode("utf-8") + return f"data:{mime_type};base64,{b64}" + + def _ensure_inside_base(path_candidate: Path, base: Path) -> Path: + """ + Resolve symlinks via resolve() and ensure the result is still under base. + """ + real_candidate = path_candidate.resolve() + real_base = base.resolve() + + if not real_candidate.is_relative_to(real_base): + raise ValueError( + "Local file path is outside the temp_base directory. Access denied." + ) + return real_candidate + + def _strip_base_prefix(absolute_path: Path, base: Path) -> str: + """ + Strip base prefix and normalize path. + """ + return str(absolute_path.relative_to(base)) + + # Check if this is a cloud storage path + cloud_storage = await get_cloud_storage_handler() + if cloud_storage.is_cloud_path(file): + # Download from cloud storage and store locally + cloud_content = await cloud_storage.retrieve_file( + file, user_id=user_id, graph_exec_id=graph_exec_id + ) + + # Generate filename from cloud path + _, path_part = cloud_storage.parse_cloud_path(file) + filename = Path(path_part).name or f"{uuid.uuid4()}.bin" + target_path = _ensure_inside_base(base_path / filename, base_path) + + # Virus scan the cloud content before writing locally + await scan_content_safe(cloud_content, filename=filename) + target_path.write_bytes(cloud_content) + + # Process file + elif file.startswith("data:"): + # Data URI + match = re.match(r"^data:([^;]+);base64,(.*)$", file, re.DOTALL) + if not match: + raise ValueError( + "Invalid data URI format. Expected data:;base64," + ) + mime_type = match.group(1).strip().lower() + b64_content = match.group(2).strip() + + # Generate filename and decode + extension = _extension_from_mime(mime_type) + filename = f"{uuid.uuid4()}{extension}" + target_path = _ensure_inside_base(base_path / filename, base_path) + content = base64.b64decode(b64_content) + + # Virus scan the base64 content before writing + await scan_content_safe(content, filename=filename) + target_path.write_bytes(content) + + elif file.startswith(("http://", "https://")): + # URL + parsed_url = urlparse(file) + filename = Path(parsed_url.path).name or f"{uuid.uuid4()}" + target_path = _ensure_inside_base(base_path / filename, base_path) + + # Download and save + resp = await Requests().get(file) + + # Virus scan the downloaded content before writing + await scan_content_safe(resp.content, filename=filename) + target_path.write_bytes(resp.content) + + else: + # Local path + target_path = _ensure_inside_base(base_path / file, base_path) + if not target_path.is_file(): + raise ValueError(f"Local file does not exist: {target_path}") + + # Return result + if return_content: + return MediaFileType(_file_to_data_uri(target_path)) + else: + return MediaFileType(_strip_base_prefix(target_path, base_path)) + + +def get_mime_type(file: str) -> str: + """ + Get the MIME type of a file, whether it's a data URI, URL, or local path. + """ + if file.startswith("data:"): + match = re.match(r"^data:([^;]+);base64,", file) + return match.group(1) if match else "application/octet-stream" + + elif file.startswith(("http://", "https://")): + parsed_url = urlparse(file) + mime_type, _ = mimetypes.guess_type(parsed_url.path) + return mime_type or "application/octet-stream" + + else: + mime_type, _ = mimetypes.guess_type(file) + return mime_type or "application/octet-stream" diff --git a/autogpt_platform/backend/backend/util/file_test.py b/autogpt_platform/backend/backend/util/file_test.py new file mode 100644 index 000000000000..cd4fc697069a --- /dev/null +++ b/autogpt_platform/backend/backend/util/file_test.py @@ -0,0 +1,238 @@ +""" +Tests for cloud storage integration in file utilities. +""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from backend.util.file import store_media_file +from backend.util.type import MediaFileType + + +class TestFileCloudIntegration: + """Test cases for cloud storage integration in file utilities.""" + + @pytest.mark.asyncio + async def test_store_media_file_cloud_path(self): + """Test storing a file from cloud storage path.""" + graph_exec_id = "test-exec-123" + cloud_path = "gcs://test-bucket/uploads/456/source.txt" + cloud_content = b"cloud file content" + + with patch( + "backend.util.file.get_cloud_storage_handler" + ) as mock_handler_getter, patch( + "backend.util.file.scan_content_safe" + ) as mock_scan, patch( + "backend.util.file.Path" + ) as mock_path_class: + + # Mock cloud storage handler + mock_handler = MagicMock() + mock_handler.is_cloud_path.return_value = True + mock_handler.parse_cloud_path.return_value = ( + "gcs", + "test-bucket/uploads/456/source.txt", + ) + mock_handler.retrieve_file = AsyncMock(return_value=cloud_content) + mock_handler_getter.return_value = mock_handler + + # Mock virus scanner + mock_scan.return_value = None + + # Mock file system operations + mock_base_path = MagicMock() + mock_target_path = MagicMock() + mock_resolved_path = MagicMock() + + mock_path_class.return_value = mock_base_path + mock_base_path.mkdir = MagicMock() + mock_base_path.__truediv__ = MagicMock(return_value=mock_target_path) + mock_target_path.resolve.return_value = mock_resolved_path + mock_resolved_path.is_relative_to.return_value = True + mock_resolved_path.write_bytes = MagicMock() + mock_resolved_path.relative_to.return_value = Path("source.txt") + + # Configure the main Path mock to handle filename extraction + # When Path(path_part) is called, it should return a mock with .name = "source.txt" + mock_path_for_filename = MagicMock() + mock_path_for_filename.name = "source.txt" + + # The Path constructor should return different mocks for different calls + def path_constructor(*args, **kwargs): + if len(args) == 1 and "source.txt" in str(args[0]): + return mock_path_for_filename + else: + return mock_base_path + + mock_path_class.side_effect = path_constructor + + result = await store_media_file( + graph_exec_id, + MediaFileType(cloud_path), + "test-user-123", + return_content=False, + ) + + # Verify cloud storage operations + mock_handler.is_cloud_path.assert_called_once_with(cloud_path) + mock_handler.parse_cloud_path.assert_called_once_with(cloud_path) + mock_handler.retrieve_file.assert_called_once_with( + cloud_path, user_id="test-user-123", graph_exec_id=graph_exec_id + ) + + # Verify virus scan + mock_scan.assert_called_once_with(cloud_content, filename="source.txt") + + # Verify file operations + mock_resolved_path.write_bytes.assert_called_once_with(cloud_content) + + # Result should be the relative path + assert str(result) == "source.txt" + + @pytest.mark.asyncio + async def test_store_media_file_cloud_path_return_content(self): + """Test storing a file from cloud storage and returning content.""" + graph_exec_id = "test-exec-123" + cloud_path = "gcs://test-bucket/uploads/456/image.png" + cloud_content = b"\\x89PNG\\r\\n\\x1a\\n\\x00\\x00\\x00\\rIHDR" # PNG header + + with patch( + "backend.util.file.get_cloud_storage_handler" + ) as mock_handler_getter, patch( + "backend.util.file.scan_content_safe" + ) as mock_scan, patch( + "backend.util.file.get_mime_type" + ) as mock_mime, patch( + "backend.util.file.base64.b64encode" + ) as mock_b64, patch( + "backend.util.file.Path" + ) as mock_path_class: + + # Mock cloud storage handler + mock_handler = MagicMock() + mock_handler.is_cloud_path.return_value = True + mock_handler.parse_cloud_path.return_value = ( + "gcs", + "test-bucket/uploads/456/image.png", + ) + mock_handler.retrieve_file = AsyncMock(return_value=cloud_content) + mock_handler_getter.return_value = mock_handler + + # Mock other operations + mock_scan.return_value = None + mock_mime.return_value = "image/png" + mock_b64.return_value.decode.return_value = "iVBORw0KGgoAAAANSUhEUgA=" + + # Mock file system operations + mock_base_path = MagicMock() + mock_target_path = MagicMock() + mock_resolved_path = MagicMock() + + mock_path_class.return_value = mock_base_path + mock_base_path.mkdir = MagicMock() + mock_base_path.__truediv__ = MagicMock(return_value=mock_target_path) + mock_target_path.resolve.return_value = mock_resolved_path + mock_resolved_path.is_relative_to.return_value = True + mock_resolved_path.write_bytes = MagicMock() + mock_resolved_path.read_bytes.return_value = cloud_content + + # Mock Path constructor for filename extraction + mock_path_obj = MagicMock() + mock_path_obj.name = "image.png" + with patch("backend.util.file.Path", return_value=mock_path_obj): + result = await store_media_file( + graph_exec_id, + MediaFileType(cloud_path), + "test-user-123", + return_content=True, + ) + + # Verify result is a data URI + assert str(result).startswith("data:image/png;base64,") + + @pytest.mark.asyncio + async def test_store_media_file_non_cloud_path(self): + """Test that non-cloud paths are handled normally.""" + graph_exec_id = "test-exec-123" + data_uri = "data:text/plain;base64,SGVsbG8gd29ybGQ=" + + with patch( + "backend.util.file.get_cloud_storage_handler" + ) as mock_handler_getter, patch( + "backend.util.file.scan_content_safe" + ) as mock_scan, patch( + "backend.util.file.base64.b64decode" + ) as mock_b64decode, patch( + "backend.util.file.uuid.uuid4" + ) as mock_uuid, patch( + "backend.util.file.Path" + ) as mock_path_class: + + # Mock cloud storage handler + mock_handler = MagicMock() + mock_handler.is_cloud_path.return_value = False + mock_handler.retrieve_file = ( + AsyncMock() + ) # Add this even though it won't be called + mock_handler_getter.return_value = mock_handler + + # Mock other operations + mock_scan.return_value = None + mock_b64decode.return_value = b"Hello world" + mock_uuid.return_value = "test-uuid-789" + + # Mock file system operations + mock_base_path = MagicMock() + mock_target_path = MagicMock() + mock_resolved_path = MagicMock() + + mock_path_class.return_value = mock_base_path + mock_base_path.mkdir = MagicMock() + mock_base_path.__truediv__ = MagicMock(return_value=mock_target_path) + mock_target_path.resolve.return_value = mock_resolved_path + mock_resolved_path.is_relative_to.return_value = True + mock_resolved_path.write_bytes = MagicMock() + mock_resolved_path.relative_to.return_value = Path("test-uuid-789.txt") + + await store_media_file( + graph_exec_id, + MediaFileType(data_uri), + "test-user-123", + return_content=False, + ) + + # Verify cloud handler was checked but not used for retrieval + mock_handler.is_cloud_path.assert_called_once_with(data_uri) + mock_handler.retrieve_file.assert_not_called() + + # Verify normal data URI processing occurred + mock_b64decode.assert_called_once() + mock_resolved_path.write_bytes.assert_called_once_with(b"Hello world") + + @pytest.mark.asyncio + async def test_store_media_file_cloud_retrieval_error(self): + """Test error handling when cloud retrieval fails.""" + graph_exec_id = "test-exec-123" + cloud_path = "gcs://test-bucket/nonexistent.txt" + + with patch( + "backend.util.file.get_cloud_storage_handler" + ) as mock_handler_getter: + + # Mock cloud storage handler to raise error + mock_handler = AsyncMock() + mock_handler.is_cloud_path.return_value = True + mock_handler.retrieve_file.side_effect = FileNotFoundError( + "File not found in cloud storage" + ) + mock_handler_getter.return_value = mock_handler + + with pytest.raises( + FileNotFoundError, match="File not found in cloud storage" + ): + await store_media_file( + graph_exec_id, MediaFileType(cloud_path), "test-user-123" + ) diff --git a/autogpt_platform/backend/backend/util/json.py b/autogpt_platform/backend/backend/util/json.py index 7f88917414c4..e425483c3148 100644 --- a/autogpt_platform/backend/backend/util/json.py +++ b/autogpt_platform/backend/backend/util/json.py @@ -1,32 +1,69 @@ import json -from typing import Any, Type, TypeVar, overload +from typing import Any, Type, TypeGuard, TypeVar, overload import jsonschema from fastapi.encoders import jsonable_encoder +from prisma import Json +from pydantic import BaseModel from .type import type_match def to_dict(data) -> dict: + if isinstance(data, BaseModel): + data = data.model_dump() return jsonable_encoder(data) -def dumps(data) -> str: - return json.dumps(jsonable_encoder(data)) +def dumps(data: Any, *args: Any, **kwargs: Any) -> str: + """ + Serialize data to JSON string with automatic conversion of Pydantic models and complex types. + + This function converts the input data to a JSON-serializable format using FastAPI's + jsonable_encoder before dumping to JSON. It handles Pydantic models, complex types, + and ensures proper serialization. + + Parameters + ---------- + data : Any + The data to serialize. Can be any type including Pydantic models, dicts, lists, etc. + *args : Any + Additional positional arguments passed to json.dumps() + **kwargs : Any + Additional keyword arguments passed to json.dumps() (e.g., indent, separators) + + Returns + ------- + str + JSON string representation of the data + + Examples + -------- + >>> dumps({"name": "Alice", "age": 30}) + '{"name": "Alice", "age": 30}' + + >>> dumps(pydantic_model_instance, indent=2) + '{\n "field1": "value1",\n "field2": "value2"\n}' + """ + return json.dumps(to_dict(data), *args, **kwargs) T = TypeVar("T") @overload -def loads(data: str, *args, target_type: Type[T], **kwargs) -> T: ... +def loads(data: str | bytes, *args, target_type: Type[T], **kwargs) -> T: ... @overload -def loads(data: str, *args, **kwargs) -> Any: ... +def loads(data: str | bytes, *args, **kwargs) -> Any: ... -def loads(data: str, *args, target_type: Type[T] | None = None, **kwargs) -> Any: +def loads( + data: str | bytes, *args, target_type: Type[T] | None = None, **kwargs +) -> Any: + if isinstance(data, bytes): + data = data.decode("utf-8") parsed = json.loads(data, *args, **kwargs) if target_type: return type_match(parsed, target_type) @@ -45,3 +82,33 @@ def validate_with_jsonschema( return None except jsonschema.ValidationError as e: return str(e) + + +def is_list_of_basemodels(value: object) -> TypeGuard[list[BaseModel]]: + return isinstance(value, list) and all( + isinstance(item, BaseModel) for item in value + ) + + +def convert_pydantic_to_json(output_data: Any) -> Any: + if isinstance(output_data, BaseModel): + return output_data.model_dump() + if is_list_of_basemodels(output_data): + return [item.model_dump() for item in output_data] + return output_data + + +def SafeJson(data: Any) -> Json: + """Safely serialize data and return Prisma's Json type.""" + if isinstance(data, BaseModel): + return Json( + data.model_dump( + mode="json", + warnings="error", + exclude_none=True, + fallback=lambda v: None, + ) + ) + # Round-trip through JSON to ensure proper serialization with fallback for non-serializable values + json_string = dumps(data, default=lambda v: None) + return Json(json.loads(json_string)) diff --git a/autogpt_platform/backend/backend/util/logging.py b/autogpt_platform/backend/backend/util/logging.py index 63166a84c23b..f07d154dab94 100644 --- a/autogpt_platform/backend/backend/util/logging.py +++ b/autogpt_platform/backend/backend/util/logging.py @@ -1,11 +1,11 @@ +import logging + from backend.util.settings import AppEnvironment, BehaveAs, Settings settings = Settings() def configure_logging(): - import logging - import autogpt_libs.logging.config if ( @@ -18,3 +18,59 @@ def configure_logging(): # Silence httpx logger logging.getLogger("httpx").setLevel(logging.WARNING) + + +class TruncatedLogger: + def __init__( + self, + logger: logging.Logger, + prefix: str = "", + metadata: dict | None = None, + max_length: int = 1000, + ): + self.logger = logger + self.metadata = metadata or {} + self.max_length = max_length + self.prefix = prefix + + def info(self, msg: str, **extra): + msg = self._wrap(msg, **extra) + self.logger.info(msg, extra=self._get_metadata(**extra)) + + def warning(self, msg: str, **extra): + msg = self._wrap(msg, **extra) + self.logger.warning(msg, extra=self._get_metadata(**extra)) + + def error(self, msg: str, **extra): + msg = self._wrap(msg, **extra) + self.logger.error(msg, extra=self._get_metadata(**extra)) + + def debug(self, msg: str, **extra): + msg = self._wrap(msg, **extra) + self.logger.debug(msg, extra=self._get_metadata(**extra)) + + def exception(self, msg: str, **extra): + msg = self._wrap(msg, **extra) + self.logger.exception(msg, extra=self._get_metadata(**extra)) + + def _get_metadata(self, **extra): + metadata = {**self.metadata, **extra} + return {"json_fields": metadata} if metadata else {} + + def _wrap(self, msg: str, **extra): + extra_msg = str(extra or "") + text = f"{self.prefix} {msg} {extra_msg}" + if len(text) > self.max_length: + half = (self.max_length - 3) // 2 + text = text[:half] + "..." + text[-half:] + return text + + +class PrefixFilter(logging.Filter): + def __init__(self, prefix: str): + super().__init__() + self.prefix = prefix + + def filter(self, record): + record.msg = f"{self.prefix} {record.msg}" + return True diff --git a/autogpt_platform/backend/backend/util/metrics.py b/autogpt_platform/backend/backend/util/metrics.py index 6381028fe05c..ff92537d4ea6 100644 --- a/autogpt_platform/backend/backend/util/metrics.py +++ b/autogpt_platform/backend/backend/util/metrics.py @@ -1,8 +1,75 @@ +import logging +from enum import Enum + import sentry_sdk +from pydantic import SecretStr +from sentry_sdk.integrations.anthropic import AnthropicIntegration +from sentry_sdk.integrations.logging import LoggingIntegration from backend.util.settings import Settings +settings = Settings() + + +class DiscordChannel(str, Enum): + PLATFORM = "platform" # For platform/system alerts + PRODUCT = "product" # For product alerts (low balance, zero balance, etc.) + def sentry_init(): - sentry_dsn = Settings().secrets.sentry_dsn - sentry_sdk.init(dsn=sentry_dsn, traces_sample_rate=1.0, profiles_sample_rate=1.0) + sentry_dsn = settings.secrets.sentry_dsn + sentry_sdk.init( + dsn=sentry_dsn, + traces_sample_rate=1.0, + profiles_sample_rate=1.0, + environment=f"app:{settings.config.app_env.value}-behave:{settings.config.behave_as.value}", + _experiments={"enable_logs": True}, + integrations=[ + LoggingIntegration(sentry_logs_level=logging.INFO), + AnthropicIntegration( + include_prompts=False, + ), + ], + ) + + +def sentry_capture_error(error: Exception): + sentry_sdk.capture_exception(error) + sentry_sdk.flush() + + +async def discord_send_alert( + content: str, channel: DiscordChannel = DiscordChannel.PLATFORM +): + from backend.blocks.discord.bot_blocks import SendDiscordMessageBlock + from backend.data.model import APIKeyCredentials, CredentialsMetaInput, ProviderName + + creds = APIKeyCredentials( + provider="discord", + api_key=SecretStr(settings.secrets.discord_bot_token), + title="Provide Discord Bot Token for the platform alert", + expires_at=None, + ) + + # Select channel based on enum + if channel == DiscordChannel.PLATFORM: + channel_name = settings.config.platform_alert_discord_channel + elif channel == DiscordChannel.PRODUCT: + channel_name = settings.config.product_alert_discord_channel + else: + channel_name = settings.config.platform_alert_discord_channel + + return await SendDiscordMessageBlock().run_once( + SendDiscordMessageBlock.Input( + credentials=CredentialsMetaInput( + id=creds.id, + title=creds.title, + type=creds.type, + provider=ProviderName.DISCORD, + ), + message_content=content, + channel_name=channel_name, + ), + "status", + credentials=creds, + ) diff --git a/autogpt_platform/backend/backend/util/models.py b/autogpt_platform/backend/backend/util/models.py new file mode 100644 index 000000000000..a4b21af90043 --- /dev/null +++ b/autogpt_platform/backend/backend/util/models.py @@ -0,0 +1,29 @@ +""" +Shared models and types used across the backend to avoid circular imports. +""" + +import pydantic + + +class Pagination(pydantic.BaseModel): + total_items: int = pydantic.Field( + description="Total number of items.", examples=[42] + ) + total_pages: int = pydantic.Field( + description="Total number of pages.", examples=[2] + ) + current_page: int = pydantic.Field( + description="Current_page page number.", examples=[1] + ) + page_size: int = pydantic.Field( + description="Number of items per page.", examples=[25] + ) + + @staticmethod + def empty() -> "Pagination": + return Pagination( + total_items=0, + total_pages=0, + current_page=0, + page_size=0, + ) diff --git a/autogpt_platform/backend/backend/util/process.py b/autogpt_platform/backend/backend/util/process.py index 218ac749d0aa..4e37f960d863 100644 --- a/autogpt_platform/backend/backend/util/process.py +++ b/autogpt_platform/backend/backend/util/process.py @@ -3,7 +3,7 @@ import signal import sys from abc import ABC, abstractmethod -from multiprocessing import Process, set_start_method +from multiprocessing import Process, get_all_start_methods, set_start_method from typing import Optional from backend.util.logging import configure_logging @@ -28,8 +28,14 @@ class AppProcess(ABC): """ process: Optional[Process] = None + cleaned_up = False + + if "forkserver" in get_all_start_methods(): + set_start_method("forkserver", force=True) + else: + logger.warning("Forkserver start method is not available. Using spawn instead.") + set_start_method("spawn", force=True) - set_start_method("spawn", force=True) configure_logging() sentry_init() @@ -42,11 +48,11 @@ def run(self): """ pass - @classmethod @property - def service_name(cls) -> str: - return cls.__name__ + def service_name(self) -> str: + return self.__class__.__name__ + @abstractmethod def cleanup(self): """ Implement this method on a subclass to do post-execution cleanup, @@ -54,14 +60,9 @@ def cleanup(self): """ pass - def health_check(self): - """ - A method to check the health of the process. - """ - pass - def execute_run_command(self, silent): signal.signal(signal.SIGTERM, self._self_terminate) + signal.signal(signal.SIGINT, self._self_terminate) try: if silent: @@ -71,12 +72,30 @@ def execute_run_command(self, silent): set_service_name(self.service_name) logger.info(f"[{self.service_name}] Starting...") self.run() - except (KeyboardInterrupt, SystemExit) as e: - logger.warning(f"[{self.service_name}] Terminated: {e}; quitting...") + except BaseException as e: + logger.warning( + f"[{self.service_name}] Termination request: {type(e).__name__}; {e} executing cleanup." + ) + finally: + self.cleanup() + logger.info(f"[{self.service_name}] Terminated.") + + @staticmethod + def llprint(message: str): + """ + Low-level print/log helper function for use in signal handlers. + Regular log/print statements are not allowed in signal handlers. + """ + os.write(sys.stdout.fileno(), (message + "\n").encode()) def _self_terminate(self, signum: int, frame): - self.cleanup() - sys.exit(0) + if not self.cleaned_up: + self.cleaned_up = True + sys.exit(0) + else: + self.llprint( + f"[{self.service_name}] Received exit signal {signum}, but cleanup is already underway." + ) # Methods that are executed OUTSIDE the process # @@ -108,7 +127,8 @@ def start(self, background: bool = False, silent: bool = False, **proc_args) -> **proc_args, ) self.process.start() - self.health_check() + logger.info(f"[{self.service_name}] started with PID {self.process.pid}") + return self.process.pid or 0 def stop(self): @@ -120,4 +140,6 @@ def stop(self): self.process.terminate() self.process.join() + + logger.info(f"[{self.service_name}] with PID {self.process.pid} stopped") self.process = None diff --git a/autogpt_platform/backend/backend/util/prompt.py b/autogpt_platform/backend/backend/util/prompt.py new file mode 100644 index 000000000000..2640e038dc5a --- /dev/null +++ b/autogpt_platform/backend/backend/util/prompt.py @@ -0,0 +1,206 @@ +from copy import deepcopy +from typing import Any + +from tiktoken import encoding_for_model + +from backend.util import json + +# ---------------------------------------------------------------------------# +# INTERNAL UTILITIES # +# ---------------------------------------------------------------------------# + + +def _tok_len(text: str, enc) -> int: + """True token length of *text* in tokenizer *enc* (no wrapper cost).""" + return len(enc.encode(str(text))) + + +def _msg_tokens(msg: dict, enc) -> int: + """ + OpenAI counts ≈3 wrapper tokens per chat message, plus 1 if "name" + is present, plus the tokenised content length. + """ + WRAPPER = 3 + (1 if "name" in msg else 0) + return WRAPPER + _tok_len(msg.get("content") or "", enc) + + +def _truncate_middle_tokens(text: str, enc, max_tok: int) -> str: + """ + Return *text* shortened to ≈max_tok tokens by keeping the head & tail + and inserting an ellipsis token in the middle. + """ + ids = enc.encode(str(text)) + if len(ids) <= max_tok: + return text # nothing to do + + # Split the allowance between the two ends: + head = max_tok // 2 - 1 # -1 for the ellipsis + tail = max_tok - head - 1 + mid = enc.encode(" … ") + return enc.decode(ids[:head] + mid + ids[-tail:]) + + +# ---------------------------------------------------------------------------# +# PUBLIC API # +# ---------------------------------------------------------------------------# + + +def compress_prompt( + messages: list[dict], + target_tokens: int, + *, + model: str = "gpt-4o", + reserve: int = 2_048, + start_cap: int = 8_192, + floor_cap: int = 128, + lossy_ok: bool = True, +) -> list[dict]: + """ + Shrink *messages* so that:: + + token_count(prompt) + reserve ≤ target_tokens + + Strategy + -------- + 1. **Token-aware truncation** – progressively halve a per-message cap + (`start_cap`, `start_cap/2`, … `floor_cap`) and apply it to the + *content* of every message except the first and last. Tool shells + are included: we keep the envelope but shorten huge payloads. + 2. **Middle-out deletion** – if still over the limit, delete whole + messages working outward from the centre, **skipping** any message + that contains ``tool_calls`` or has ``role == "tool"``. + 3. **Last-chance trim** – if still too big, truncate the *first* and + *last* message bodies down to `floor_cap` tokens. + 4. If the prompt is *still* too large: + • raise ``ValueError`` when ``lossy_ok == False`` (default) + • return the partially-trimmed prompt when ``lossy_ok == True`` + + Parameters + ---------- + messages Complete chat history (will be deep-copied). + model Model name; passed to tiktoken to pick the right + tokenizer (gpt-4o → 'o200k_base', others fallback). + target_tokens Hard ceiling for prompt size **excluding** the model's + forthcoming answer. + reserve How many tokens you want to leave available for that + answer (`max_tokens` in your subsequent completion call). + start_cap Initial per-message truncation ceiling (tokens). + floor_cap Lowest cap we'll accept before moving to deletions. + lossy_ok If *True* return best-effort prompt instead of raising + after all trim passes have been exhausted. + + Returns + ------- + list[dict] – A *new* messages list that abides by the rules above. + """ + enc = encoding_for_model(model) # best-match tokenizer + msgs = deepcopy(messages) # never mutate caller + + def total_tokens() -> int: + """Current size of *msgs* in tokens.""" + return sum(_msg_tokens(m, enc) for m in msgs) + + original_token_count = total_tokens() + if original_token_count + reserve <= target_tokens: + return msgs + + # ---- STEP 0 : normalise content -------------------------------------- + # Convert non-string payloads to strings so token counting is coherent. + for m in msgs[1:-1]: # keep the first & last intact + if not isinstance(m.get("content"), str) and m.get("content") is not None: + # Reasonable 20k-char ceiling prevents pathological blobs + content_str = json.dumps(m["content"], separators=(",", ":")) + if len(content_str) > 20_000: + content_str = _truncate_middle_tokens(content_str, enc, 20_000) + m["content"] = content_str + + # ---- STEP 1 : token-aware truncation --------------------------------- + cap = start_cap + while total_tokens() + reserve > target_tokens and cap >= floor_cap: + for m in msgs[1:-1]: # keep first & last intact + if _tok_len(m.get("content") or "", enc) > cap: + m["content"] = _truncate_middle_tokens(m["content"], enc, cap) + cap //= 2 # tighten the screw + + # ---- STEP 2 : middle-out deletion ----------------------------------- + while total_tokens() + reserve > target_tokens and len(msgs) > 2: + centre = len(msgs) // 2 + # Build a symmetrical centre-out index walk: centre, centre+1, centre-1, ... + order = [centre] + [ + i + for pair in zip(range(centre + 1, len(msgs) - 1), range(centre - 1, 0, -1)) + for i in pair + ] + removed = False + for i in order: + msg = msgs[i] + if "tool_calls" in msg or msg.get("role") == "tool": + continue # protect tool shells + del msgs[i] + removed = True + break + if not removed: # nothing more we can drop + break + + # ---- STEP 3 : final safety-net trim on first & last ------------------ + cap = start_cap + while total_tokens() + reserve > target_tokens and cap >= floor_cap: + for idx in (0, -1): # first and last + text = msgs[idx].get("content") or "" + if _tok_len(text, enc) > cap: + msgs[idx]["content"] = _truncate_middle_tokens(text, enc, cap) + cap //= 2 # tighten the screw + + # ---- STEP 4 : success or fail-gracefully ----------------------------- + if total_tokens() + reserve > target_tokens and not lossy_ok: + raise ValueError( + "compress_prompt: prompt still exceeds budget " + f"({total_tokens() + reserve} > {target_tokens})." + ) + + return msgs + + +def estimate_token_count( + messages: list[dict], + *, + model: str = "gpt-4o", +) -> int: + """ + Return the true token count of *messages* when encoded for *model*. + + Parameters + ---------- + messages Complete chat history. + model Model name; passed to tiktoken to pick the right + tokenizer (gpt-4o → 'o200k_base', others fallback). + + Returns + ------- + int – Token count. + """ + enc = encoding_for_model(model) # best-match tokenizer + return sum(_msg_tokens(m, enc) for m in messages) + + +def estimate_token_count_str( + text: Any, + *, + model: str = "gpt-4o", +) -> int: + """ + Return the true token count of *text* when encoded for *model*. + + Parameters + ---------- + text Input text. + model Model name; passed to tiktoken to pick the right + tokenizer (gpt-4o → 'o200k_base', others fallback). + + Returns + ------- + int – Token count. + """ + enc = encoding_for_model(model) # best-match tokenizer + text = json.dumps(text) if not isinstance(text, str) else text + return _tok_len(text, enc) diff --git a/autogpt_platform/backend/backend/util/request.py b/autogpt_platform/backend/backend/util/request.py index f1eae6c244ce..7c7c684c48b2 100644 --- a/autogpt_platform/backend/backend/util/request.py +++ b/autogpt_platform/backend/backend/util/request.py @@ -1,17 +1,27 @@ +import asyncio import ipaddress import re import socket -from typing import Callable -from urllib.parse import urlparse, urlunparse +import ssl +from io import BytesIO +from typing import Any, Callable, Optional +from urllib.parse import ParseResult as URL +from urllib.parse import quote, urljoin, urlparse +import aiohttp import idna -import requests as req +from aiohttp import FormData, abc +from tenacity import retry, retry_if_result, wait_exponential_jitter -from backend.util.settings import Config +from backend.util.json import json + +# Retry status codes for which we will automatically retry the request +THROTTLE_RETRY_STATUS_CODES: set[int] = {429, 500, 502, 503, 504, 408} # List of IP networks to block BLOCKED_IP_NETWORKS = [ # --8<-- [start:BLOCKED_IP_NETWORKS] + # IPv4 Ranges ipaddress.ip_network("0.0.0.0/8"), # "This" Network ipaddress.ip_network("10.0.0.0/8"), # Private-Use ipaddress.ip_network("127.0.0.0/8"), # Loopback @@ -20,6 +30,11 @@ ipaddress.ip_network("192.168.0.0/16"), # Private-Use ipaddress.ip_network("224.0.0.0/4"), # Multicast ipaddress.ip_network("240.0.0.0/4"), # Reserved for Future Use + # IPv6 Ranges + ipaddress.ip_network("::1/128"), # Loopback + ipaddress.ip_network("fc00::/7"), # Unique local addresses (ULA) + ipaddress.ip_network("fe80::/10"), # Link-local + ipaddress.ip_network("ff00::/8"), # Multicast # --8<-- [end:BLOCKED_IP_NETWORKS] ] @@ -27,18 +42,6 @@ HOSTNAME_REGEX = re.compile(r"^[A-Za-z0-9.-]+$") # Basic DNS-safe hostname pattern -def _canonicalize_url(url: str) -> str: - # Strip spaces and trailing slashes - url = url.strip().strip("/") - # Ensure the URL starts with http:// or https:// - if not url.startswith(("http://", "https://")): - url = "http://" + url - - # Replace backslashes with forward slashes to avoid parsing ambiguities - url = url.replace("\\", "/") - return url - - def _is_ip_blocked(ip: str) -> bool: """ Checks if the IP address is in a blocked network. @@ -47,13 +50,93 @@ def _is_ip_blocked(ip: str) -> bool: return any(ip_addr in network for network in BLOCKED_IP_NETWORKS) -def validate_url(url: str, trusted_origins: list[str]) -> str: +def _remove_insecure_headers(headers: dict, old_url: URL, new_url: URL) -> dict: """ - Validates the URL to prevent SSRF attacks by ensuring it does not point to a private - or untrusted IP address, unless whitelisted. + Removes sensitive headers (Authorization, Proxy-Authorization, Cookie) + if the scheme/host/port of new_url differ from old_url. + """ + if ( + (old_url.scheme != new_url.scheme) + or (old_url.hostname != new_url.hostname) + or (old_url.port != new_url.port) + ): + headers.pop("Authorization", None) + headers.pop("Proxy-Authorization", None) + headers.pop("Cookie", None) + return headers + + +class HostResolver(abc.AbstractResolver): """ - url = _canonicalize_url(url) + A custom resolver that connects to specified IP addresses but still + sets the TLS SNI to the original host name so the cert can match. + """ + + def __init__(self, ssl_hostname: str, ip_addresses: list[str]): + self.ssl_hostname = ssl_hostname + self.ip_addresses = ip_addresses + self._default = aiohttp.AsyncResolver() + + async def resolve(self, host, port=0, family=socket.AF_INET): + if host == self.ssl_hostname: + results = [] + for ip in self.ip_addresses: + results.append( + { + "hostname": self.ssl_hostname, + "host": ip, + "port": port, + "family": family, + "proto": 0, + "flags": socket.AI_NUMERICHOST, + } + ) + return results + return await self._default.resolve(host, port, family) + + async def close(self): + await self._default.close() + + +async def _resolve_host(hostname: str) -> list[str]: + """ + Resolves the hostname to a list of IP addresses (IPv4 first, then IPv6). + """ + loop = asyncio.get_running_loop() + try: + infos = await loop.getaddrinfo(hostname, None) + except socket.gaierror: + raise ValueError(f"Unable to resolve IP address for hostname {hostname}") + + ip_list = [info[4][0] for info in infos] + ipv4 = [ip for ip in ip_list if ":" not in ip] + ipv6 = [ip for ip in ip_list if ":" in ip] + ip_addresses = ipv4 + ipv6 + + if not ip_addresses: + raise ValueError(f"No IP addresses found for {hostname}") + return ip_addresses + + +async def validate_url( + url: str, trusted_origins: list[str] +) -> tuple[URL, bool, list[str]]: + """ + Validates the URL to prevent SSRF attacks by ensuring it does not point + to a private, link-local, or otherwise blocked IP address — unless + the hostname is explicitly trusted. + + Returns: + str: The validated, canonicalized, parsed URL + is_trusted: Boolean indicating if the hostname is in trusted_origins + ip_addresses: List of IP addresses for the host; empty if the host is trusted + """ + # Canonicalize URL + url = url.strip("/ ").replace("\\", "/") parsed = urlparse(url) + if not parsed.scheme: + url = f"http://{url}" + parsed = urlparse(url) # Check scheme if parsed.scheme not in ALLOWED_SCHEMES: @@ -61,7 +144,7 @@ def validate_url(url: str, trusted_origins: list[str]) -> str: f"Scheme '{parsed.scheme}' is not allowed. Only HTTP/HTTPS are supported." ) - # Validate and IDNA encode the hostname + # Validate and IDNA encode hostname if not parsed.hostname: raise ValueError("Invalid URL: No hostname found.") @@ -75,44 +158,142 @@ def validate_url(url: str, trusted_origins: list[str]) -> str: if not HOSTNAME_REGEX.match(ascii_hostname): raise ValueError("Hostname contains invalid characters.") - # Rebuild the URL with the normalized, IDNA-encoded hostname - parsed = parsed._replace(netloc=ascii_hostname) - url = str(urlunparse(parsed)) + # Check if hostname is trusted + is_trusted = ascii_hostname in trusted_origins + + # If not trusted, validate IP addresses + ip_addresses: list[str] = [] + if not is_trusted: + # Resolve all IP addresses for the hostname + ip_addresses = await _resolve_host(ascii_hostname) + + # Block any IP address that belongs to a blocked range + for ip_str in ip_addresses: + if _is_ip_blocked(ip_str): + raise ValueError( + f"Access to blocked or private IP address {ip_str} " + f"for hostname {ascii_hostname} is not allowed." + ) + + return ( + URL( + parsed.scheme, + ascii_hostname, + quote(parsed.path, safe="/%:@"), + parsed.params, + parsed.query, + parsed.fragment, + ), + is_trusted, + ip_addresses, + ) + + +def pin_url(url: URL, ip_addresses: Optional[list[str]] = None) -> URL: + """ + Pins a URL to a specific IP address to prevent DNS rebinding attacks. - # Check if hostname is a trusted origin (exact match) - if ascii_hostname in trusted_origins: - return url + Args: + url: The original URL + ip_addresses: List of IP addresses corresponding to the URL's host - # Resolve all IP addresses for the hostname - try: - ip_addresses = {res[4][0] for res in socket.getaddrinfo(ascii_hostname, None)} - except socket.gaierror: - raise ValueError(f"Unable to resolve IP address for hostname {ascii_hostname}") + Returns: + pinned_url: The URL with hostname replaced with IP address + """ + if not url.hostname: + raise ValueError(f"URL has no hostname: {url}") if not ip_addresses: - raise ValueError(f"No IP addresses found for {ascii_hostname}") + # Resolve all IP addresses for the hostname + # (This call is blocking; ensure to call async _resolve_host before if possible) + ip_addresses = [] + # You may choose to raise or call synchronous resolve here; for simplicity, leave empty. - # Check if any resolved IP address falls into blocked ranges - for ip in ip_addresses: - if _is_ip_blocked(ip): - raise ValueError( - f"Access to private IP address {ip} for hostname {ascii_hostname} is not allowed." - ) + # Pin to the first valid IP (for SSRF defense) + pinned_ip = ip_addresses[0] + + # If it's IPv6, bracket it + if ":" in pinned_ip: + pinned_netloc = f"[{pinned_ip}]" + else: + pinned_netloc = pinned_ip + + if url.port: + pinned_netloc += f":{url.port}" + + return URL( + url.scheme, + pinned_netloc, + url.path, + url.params, + url.query, + url.fragment, + ) - return url + +ClientResponse = aiohttp.ClientResponse +ClientResponseError = aiohttp.ClientResponseError + + +class Response: + """ + Buffered wrapper around aiohttp.ClientResponse that does *not* require + callers to manage connection or session lifetimes. + """ + + def __init__( + self, + *, + response: ClientResponse, + url: str, + body: bytes, + ): + self.status: int = response.status + self.headers = response.headers + self.reason: str | None = response.reason + self.request_info = response.request_info + self.url: str = url + self.content: bytes = body # raw bytes + + def json(self, encoding: str | None = None, **kwargs) -> dict: + """ + Parse the body as JSON and return the resulting Python object. + """ + return json.loads( + self.content.decode(encoding or "utf-8", errors="replace"), **kwargs + ) + + def text(self, encoding: str | None = None) -> str: + """ + Decode the body to a string. Encoding is guessed from the + Content-Type header if not supplied. + """ + if encoding is None: + # Try to extract charset from headers; fall back to UTF-8 + ctype = self.headers.get("content-type", "") + match = re.search(r"charset=([^\s;]+)", ctype, flags=re.I) + encoding = match.group(1) if match else None + return self.content.decode(encoding or "utf-8", errors="replace") + + @property + def ok(self) -> bool: + return 200 <= self.status < 300 class Requests: """ - A wrapper around the requests library that validates URLs before making requests. + A wrapper around an aiohttp ClientSession that validates URLs before + making requests, preventing SSRF by blocking private networks and + other disallowed address spaces. """ def __init__( self, trusted_origins: list[str] | None = None, raise_for_status: bool = True, - extra_url_validator: Callable[[str], str] | None = None, + extra_url_validator: Callable[[URL], URL] | None = None, extra_headers: dict[str, str] | None = None, + retry_max_wait: float = 300.0, ): self.trusted_origins = [] for url in trusted_origins or []: @@ -124,50 +305,206 @@ def __init__( self.raise_for_status = raise_for_status self.extra_url_validator = extra_url_validator self.extra_headers = extra_headers + self.retry_max_wait = retry_max_wait - def request( - self, method, url, headers=None, allow_redirects=False, *args, **kwargs - ) -> req.Response: - if self.extra_headers is not None: - headers = {**(headers or {}), **self.extra_headers} - - url = validate_url(url, self.trusted_origins) - if self.extra_url_validator is not None: - url = self.extra_url_validator(url) - - response = req.request( - method, - url, - headers=headers, - allow_redirects=allow_redirects, - *args, - **kwargs, + async def request( + self, + method: str, + url: str, + *, + headers: Optional[dict] = None, + files: list[tuple[str, tuple[str, BytesIO, str]]] | None = None, + data: Any | None = None, + json: Any | None = None, + allow_redirects: bool = True, + max_redirects: int = 10, + **kwargs, + ) -> Response: + @retry( + wait=wait_exponential_jitter(max=self.retry_max_wait), + retry=retry_if_result(lambda r: r.status in THROTTLE_RETRY_STATUS_CODES), + reraise=True, ) - if self.raise_for_status: - response.raise_for_status() - - return response - - def get(self, url, *args, **kwargs) -> req.Response: - return self.request("GET", url, *args, **kwargs) - - def post(self, url, *args, **kwargs) -> req.Response: - return self.request("POST", url, *args, **kwargs) - - def put(self, url, *args, **kwargs) -> req.Response: - return self.request("PUT", url, *args, **kwargs) - - def delete(self, url, *args, **kwargs) -> req.Response: - return self.request("DELETE", url, *args, **kwargs) - - def head(self, url, *args, **kwargs) -> req.Response: - return self.request("HEAD", url, *args, **kwargs) - - def options(self, url, *args, **kwargs) -> req.Response: - return self.request("OPTIONS", url, *args, **kwargs) + async def _make_request() -> Response: + return await self._request( + method=method, + url=url, + headers=headers, + files=files, + data=data, + json=json, + allow_redirects=allow_redirects, + max_redirects=max_redirects, + **kwargs, + ) - def patch(self, url, *args, **kwargs) -> req.Response: - return self.request("PATCH", url, *args, **kwargs) + return await _make_request() + async def _request( + self, + method: str, + url: str, + *, + headers: Optional[dict] = None, + files: list[tuple[str, tuple[str, BytesIO, str]]] | None = None, + data: Any | None = None, + json: Any | None = None, + allow_redirects: bool = True, + max_redirects: int = 10, + **kwargs, + ) -> Response: + # Convert auth tuple to aiohttp.BasicAuth if necessary + if "auth" in kwargs and isinstance(kwargs["auth"], tuple): + kwargs["auth"] = aiohttp.BasicAuth(*kwargs["auth"]) + + if files is not None: + if json is not None: + raise ValueError( + "Cannot mix file uploads with JSON body; " + "use 'data' for extra form fields instead." + ) + + form = FormData(quote_fields=False) + # add normal form fields first + if isinstance(data, dict): + for k, v in data.items(): + form.add_field(k, str(v)) + elif data is not None: + raise ValueError( + "When uploading files, 'data' must be a dict of form fields." + ) + + # add the file parts + for field_name, (filename, fh, content_type) in files: + form.add_field( + name=field_name, + value=fh, + filename=filename, + content_type=content_type or "application/octet-stream", + ) + + data = form + + # Validate URL and get trust status + parsed_url, is_trusted, ip_addresses = await validate_url( + url, self.trusted_origins + ) -requests = Requests(trusted_origins=Config().trust_endpoints_for_requests) + # Apply any extra user-defined validation/transformation + if self.extra_url_validator is not None: + parsed_url = self.extra_url_validator(parsed_url) + + # Pin the URL if untrusted + hostname = parsed_url.hostname + if hostname is None: + raise ValueError(f"Invalid URL: Unable to determine hostname of {url}") + + original_url = parsed_url.geturl() + connector: Optional[aiohttp.TCPConnector] = None + if not is_trusted: + # Replace hostname with IP for connection but preserve SNI via resolver + resolver = HostResolver(ssl_hostname=hostname, ip_addresses=ip_addresses) + ssl_context = ssl.create_default_context() + connector = aiohttp.TCPConnector(resolver=resolver, ssl=ssl_context) + session_kwargs = {} + if connector: + session_kwargs["connector"] = connector + + # Merge any extra headers + req_headers = dict(headers) if headers else {} + if self.extra_headers is not None: + req_headers.update(self.extra_headers) + + # Override Host header if using IP connection + if connector: + req_headers["Host"] = hostname + + # Override data if files are provided + + async with aiohttp.ClientSession(**session_kwargs) as session: + # Perform the request with redirects disabled for manual handling + async with session.request( + method, + parsed_url.geturl(), + headers=req_headers, + allow_redirects=False, + data=data, + json=json, + **kwargs, + ) as response: + + if self.raise_for_status: + try: + response.raise_for_status() + except ClientResponseError as e: + body = await response.read() + raise Exception( + f"HTTP {response.status} Error: {response.reason}, Body: {body.decode(errors='replace')}" + ) from e + + # If allowed and a redirect is received, follow the redirect manually + if allow_redirects and response.status in (301, 302, 303, 307, 308): + if max_redirects <= 0: + raise Exception("Too many redirects.") + + location = response.headers.get("Location") + if not location: + return Response( + response=response, + url=original_url, + body=await response.read(), + ) + + # The base URL is the pinned_url we just used + # so that relative redirects resolve correctly. + redirect_url = urlparse(urljoin(parsed_url.geturl(), location)) + # Carry forward the same headers but update Host + new_headers = _remove_insecure_headers( + req_headers, parsed_url, redirect_url + ) + + return await self.request( + method, + redirect_url.geturl(), + headers=new_headers, + allow_redirects=allow_redirects, + max_redirects=max_redirects - 1, + files=files, + data=data, + json=json, + **kwargs, + ) + + # Reset response URL to original host for clarity + if parsed_url.hostname != hostname: + try: + response.url = original_url # type: ignore + except Exception: + pass + + return Response( + response=response, + url=original_url, + body=await response.read(), + ) + + async def get(self, url: str, *args, **kwargs) -> Response: + return await self.request("GET", url, *args, **kwargs) + + async def post(self, url: str, *args, **kwargs) -> Response: + return await self.request("POST", url, *args, **kwargs) + + async def put(self, url: str, *args, **kwargs) -> Response: + return await self.request("PUT", url, *args, **kwargs) + + async def delete(self, url: str, *args, **kwargs) -> Response: + return await self.request("DELETE", url, *args, **kwargs) + + async def head(self, url: str, *args, **kwargs) -> Response: + return await self.request("HEAD", url, *args, **kwargs) + + async def options(self, url: str, *args, **kwargs) -> Response: + return await self.request("OPTIONS", url, *args, **kwargs) + + async def patch(self, url: str, *args, **kwargs) -> Response: + return await self.request("PATCH", url, *args, **kwargs) diff --git a/autogpt_platform/backend/backend/util/request_test.py b/autogpt_platform/backend/backend/util/request_test.py new file mode 100644 index 000000000000..57717ff77f21 --- /dev/null +++ b/autogpt_platform/backend/backend/util/request_test.py @@ -0,0 +1,112 @@ +import pytest + +from backend.util.request import pin_url, validate_url + + +@pytest.mark.parametrize( + "raw_url, trusted_origins, expected_value, should_raise", + [ + # Rejected IP ranges + ("localhost", [], None, True), + ("192.168.1.1", [], None, True), + ("127.0.0.1", [], None, True), + ("0.0.0.0", [], None, True), + # Normal URLs (should default to http:// if no scheme provided) + ("google.com/a?b=c", [], "http://google.com/a?b=c", False), + ("github.com?key=!@!@", [], "http://github.com?key=!@!@", False), + # Scheme Enforcement + ("ftp://example.com", [], None, True), + ("file://example.com", [], None, True), + # International domain converting to punycode (allowed if public) + ("http://xn--exmple-cua.com", [], "http://xn--exmple-cua.com", False), + # Invalid domain (IDNA failure) + ("http://exa◌mple.com", [], None, True), + # IPv6 addresses (loopback/blocked) + ("::1", [], None, True), + ("http://[::1]", [], None, True), + # Suspicious Characters in Hostname + ("http://example_underscore.com", [], None, True), + ("http://exa mple.com", [], None, True), + # Malformed URLs + ("http://", [], None, True), # No hostname + ("://missing-scheme", [], None, True), # Missing proper scheme + # Trusted Origins + ( + "internal-api.company.com", + ["internal-api.company.com", "10.0.0.5"], + "http://internal-api.company.com", + False, + ), + ("10.0.0.5", ["10.0.0.5"], "http://10.0.0.5", False), + # Special Characters in Path + ( + "example.com/path%20with%20spaces", + [], + "http://example.com/path%20with%20spaces", + False, + ), + # Backslashes should be replaced with forward slashes + ("http://example.com\\backslash", [], "http://example.com/backslash", False), + # Check default-scheme behavior for valid domains + ("example.com", [], "http://example.com", False), + ("https://secure.com", [], "https://secure.com", False), + # Non-ASCII Characters in Query/Fragment + ("example.com?param=äöü", [], "http://example.com?param=äöü", False), + ], +) +async def test_validate_url_no_dns_rebinding( + raw_url: str, trusted_origins: list[str], expected_value: str, should_raise: bool +): + if should_raise: + with pytest.raises(ValueError): + await validate_url(raw_url, trusted_origins) + else: + validated_url, _, _ = await validate_url(raw_url, trusted_origins) + assert validated_url.geturl() == expected_value + + +@pytest.mark.parametrize( + "hostname, resolved_ips, expect_error, expected_ip", + [ + # Multiple public IPs, none blocked + ("public-example.com", ["8.8.8.8", "9.9.9.9"], False, "8.8.8.8"), + # Includes a blocked IP (e.g. link-local 169.254.x.x) => should raise + ("rebinding.com", ["1.2.3.4", "169.254.169.254"], True, None), + # Single public IP + ("single-public.com", ["8.8.8.8"], False, "8.8.8.8"), + # Single blocked IP + ("blocked.com", ["127.0.0.1"], True, None), + ], +) +async def test_dns_rebinding_fix( + monkeypatch, + hostname: str, + resolved_ips: list[str], + expect_error: bool, + expected_ip: str, +): + """ + Tests that validate_url pins the first valid public IP address, and rejects + the domain if any of the resolved IPs are blocked (i.e., DNS Rebinding scenario). + """ + + def mock_getaddrinfo(host, port, *args, **kwargs): + # Simulate multiple IPs returned for the given hostname + return [(None, None, None, None, (ip, port)) for ip in resolved_ips] + + # Patch socket.getaddrinfo so we control the DNS resolution in the test + monkeypatch.setattr("socket.getaddrinfo", mock_getaddrinfo) + + if expect_error: + # If any IP is blocked, we expect a ValueError + with pytest.raises(ValueError): + url, _, ip_addresses = await validate_url(hostname, []) + pin_url(url, ip_addresses) + else: + url, _, ip_addresses = await validate_url(hostname, []) + pinned_url = pin_url(url, ip_addresses).geturl() + # The pinned_url should contain the first valid IP + assert pinned_url.startswith("http://") or pinned_url.startswith("https://") + assert expected_ip in pinned_url + # The unpinned URL's hostname should match our original IDNA encoded hostname + assert url.hostname == hostname diff --git a/autogpt_platform/backend/backend/util/retry.py b/autogpt_platform/backend/backend/util/retry.py index c1adab5caf26..c586281af9c2 100644 --- a/autogpt_platform/backend/backend/util/retry.py +++ b/autogpt_platform/backend/backend/util/retry.py @@ -2,16 +2,87 @@ import logging import os import threading +import time from functools import wraps from uuid import uuid4 -from tenacity import retry, stop_after_attempt, wait_exponential +from tenacity import ( + retry, + retry_if_not_exception_type, + stop_after_attempt, + wait_exponential_jitter, +) from backend.util.process import get_service_name logger = logging.getLogger(__name__) +def _create_retry_callback(context: str = ""): + """Create a retry callback with optional context.""" + + def callback(retry_state): + attempt_number = retry_state.attempt_number + exception = retry_state.outcome.exception() + func_name = getattr(retry_state.fn, "__name__", "unknown") + + prefix = f"{context}: " if context else "" + + if retry_state.outcome.failed and retry_state.next_action is None: + # Final failure + logger.error( + f"{prefix}Giving up after {attempt_number} attempts for '{func_name}': " + f"{type(exception).__name__}: {exception}" + ) + else: + # Retry attempt + logger.warning( + f"{prefix}Retry attempt {attempt_number} for '{func_name}': " + f"{type(exception).__name__}: {exception}" + ) + + return callback + + +def create_retry_decorator( + max_attempts: int = 5, + exclude_exceptions: tuple[type[BaseException], ...] = (), + max_wait: float = 30.0, + context: str = "", + reraise: bool = True, +): + """ + Create a preconfigured retry decorator with sensible defaults. + + Uses exponential backoff with jitter by default. + + Args: + max_attempts: Maximum number of attempts (default: 5) + exclude_exceptions: Tuple of exception types to not retry on + max_wait: Maximum wait time in seconds (default: 30) + context: Optional context string for log messages + reraise: Whether to reraise the final exception (default: True) + + Returns: + Configured retry decorator + """ + if exclude_exceptions: + return retry( + stop=stop_after_attempt(max_attempts), + wait=wait_exponential_jitter(max=max_wait), + before_sleep=_create_retry_callback(context), + reraise=reraise, + retry=retry_if_not_exception_type(exclude_exceptions), + ) + else: + return retry( + stop=stop_after_attempt(max_attempts), + wait=wait_exponential_jitter(max=max_wait), + before_sleep=_create_retry_callback(context), + reraise=reraise, + ) + + def _log_prefix(resource_name: str, conn_id: str): """ Returns a prefix string for logging purposes. @@ -25,8 +96,6 @@ def conn_retry( resource_name: str, action_name: str, max_retry: int = 5, - multiplier: int = 1, - min_wait: float = 1, max_wait: float = 30, ): conn_id = str(uuid4()) @@ -34,13 +103,20 @@ def conn_retry( def on_retry(retry_state): prefix = _log_prefix(resource_name, conn_id) exception = retry_state.outcome.exception() - logger.error(f"{prefix} {action_name} failed: {exception}. Retrying now...") + + if retry_state.outcome.failed and retry_state.next_action is None: + logger.error(f"{prefix} {action_name} failed after retries: {exception}") + else: + logger.warning( + f"{prefix} {action_name} failed: {exception}. Retrying now..." + ) def decorator(func): is_coroutine = asyncio.iscoroutinefunction(func) + # Use static retry configuration retry_decorator = retry( - stop=stop_after_attempt(max_retry + 1), - wait=wait_exponential(multiplier=multiplier, min=min_wait, max=max_wait), + stop=stop_after_attempt(max_retry + 1), # +1 for the initial attempt + wait=wait_exponential_jitter(max=max_wait), before_sleep=on_retry, reraise=True, ) @@ -73,3 +149,58 @@ async def async_wrapper(*args, **kwargs): return async_wrapper if is_coroutine else sync_wrapper return decorator + + +# Preconfigured retry decorator for general functions +func_retry = create_retry_decorator(max_attempts=5) + + +def continuous_retry(*, retry_delay: float = 1.0): + def decorator(func): + is_coroutine = asyncio.iscoroutinefunction(func) + + @wraps(func) + def sync_wrapper(*args, **kwargs): + counter = 0 + while True: + try: + return func(*args, **kwargs) + except Exception as exc: + counter += 1 + if counter % 10 == 0: + log = logger.exception + else: + log = logger.warning + log( + "%s failed for the %s times, error: [%s] — retrying in %.2fs", + func.__name__, + counter, + str(exc) or type(exc).__name__, + retry_delay, + ) + time.sleep(retry_delay) + + @wraps(func) + async def async_wrapper(*args, **kwargs): + while True: + counter = 0 + try: + return await func(*args, **kwargs) + except Exception as exc: + counter += 1 + if counter % 10 == 0: + log = logger.exception + else: + log = logger.warning + log( + "%s failed for the %s times, error: [%s] — retrying in %.2fs", + func.__name__, + counter, + str(exc) or type(exc).__name__, + retry_delay, + ) + await asyncio.sleep(retry_delay) + + return async_wrapper if is_coroutine else sync_wrapper + + return decorator diff --git a/autogpt_platform/backend/backend/util/retry_test.py b/autogpt_platform/backend/backend/util/retry_test.py new file mode 100644 index 000000000000..29b6a192aa93 --- /dev/null +++ b/autogpt_platform/backend/backend/util/retry_test.py @@ -0,0 +1,49 @@ +import asyncio + +import pytest + +from backend.util.retry import conn_retry + + +def test_conn_retry_sync_function(): + retry_count = 0 + + @conn_retry("Test", "Test function", max_retry=2, max_wait=0.1) + def test_function(): + nonlocal retry_count + retry_count -= 1 + if retry_count > 0: + raise ValueError("Test error") + return "Success" + + retry_count = 2 + res = test_function() + assert res == "Success" + + retry_count = 100 + with pytest.raises(ValueError) as e: + test_function() + assert str(e.value) == "Test error" + + +@pytest.mark.asyncio +async def test_conn_retry_async_function(): + retry_count = 0 + + @conn_retry("Test", "Test function", max_retry=2, max_wait=0.1) + async def test_function(): + nonlocal retry_count + await asyncio.sleep(1) + retry_count -= 1 + if retry_count > 0: + raise ValueError("Test error") + return "Success" + + retry_count = 2 + res = await test_function() + assert res == "Success" + + retry_count = 100 + with pytest.raises(ValueError) as e: + await test_function() + assert str(e.value) == "Test error" diff --git a/autogpt_platform/backend/backend/util/service.py b/autogpt_platform/backend/backend/util/service.py index dffd8e37a8a0..97ff054fbf11 100644 --- a/autogpt_platform/backend/backend/util/service.py +++ b/autogpt_platform/backend/backend/util/service.py @@ -1,122 +1,93 @@ import asyncio -import builtins +import concurrent +import concurrent.futures +import inspect import logging import os import threading import time -import typing from abc import ABC, abstractmethod -from enum import Enum -from types import NoneType, UnionType +from functools import cached_property, update_wrapper from typing import ( - Annotated, Any, Awaitable, Callable, + Concatenate, Coroutine, - Dict, - FrozenSet, - Iterator, - List, - Set, + Optional, + ParamSpec, Tuple, Type, TypeVar, - Union, cast, - get_args, - get_origin, ) -import Pyro5.api -from pydantic import BaseModel -from Pyro5 import api as pyro -from Pyro5 import config as pyro_config +import httpx +import uvicorn +from fastapi import FastAPI, Request, responses +from pydantic import BaseModel, TypeAdapter, create_model -from backend.data import db, redis -from backend.util.process import AppProcess -from backend.util.retry import conn_retry -from backend.util.settings import Config, Secrets +import backend.util.exceptions as exceptions +from backend.util.json import to_dict +from backend.util.metrics import sentry_init +from backend.util.process import AppProcess, get_service_name +from backend.util.retry import conn_retry, create_retry_decorator +from backend.util.settings import Config logger = logging.getLogger(__name__) T = TypeVar("T") C = TypeVar("C", bound=Callable) config = Config() -pyro_host = config.pyro_host -pyro_config.MAX_RETRIES = config.pyro_client_comm_retry # type: ignore -pyro_config.COMMTIMEOUT = config.pyro_client_comm_timeout # type: ignore +api_host = config.pyro_host +api_comm_retry = config.pyro_client_comm_retry +api_comm_timeout = config.pyro_client_comm_timeout +api_call_timeout = config.rpc_client_call_timeout -def expose(func: C) -> C: +def _validate_no_prisma_objects(obj: Any, path: str = "result") -> None: """ - Decorator to mark a method or class to be exposed for remote calls. - - ## ⚠️ Gotcha - Aside from "simple" types, only Pydantic models are passed unscathed *if annotated*. - Any other passed or returned class objects are converted to dictionaries by Pyro. + Recursively validate that no Prisma objects are being returned from service methods. + This enforces proper separation of layers - only application models should cross service boundaries. """ - - def wrapper(*args, **kwargs): - try: - return func(*args, **kwargs) - except Exception as e: - msg = f"Error in {func.__name__}: {e.__str__()}" - logger.exception(msg) - raise - - register_pydantic_serializers(func) - - return pyro.expose(wrapper) # type: ignore - - -def register_pydantic_serializers(func: Callable): - """Register custom serializers and deserializers for annotated Pydantic models""" - for name, annotation in func.__annotations__.items(): - try: - pydantic_types = _pydantic_models_from_type_annotation(annotation) - except Exception as e: - raise TypeError(f"Error while exposing {func.__name__}: {e.__str__()}") - - for model in pydantic_types: - logger.debug( - f"Registering Pyro (de)serializers for {func.__name__} annotation " - f"'{name}': {model.__qualname__}" - ) - pyro.register_class_to_dict(model, _make_custom_serializer(model)) - pyro.register_dict_to_class( - model.__qualname__, _make_custom_deserializer(model) + if obj is None: + return + + # Check if it's a Prisma model object + if hasattr(obj, "__class__") and hasattr(obj.__class__, "__module__"): + module_name = obj.__class__.__module__ + if module_name and "prisma.models" in module_name: + raise ValueError( + f"Prisma object {obj.__class__.__name__} found in {path}. " + "Service methods must return application models, not Prisma objects. " + f"Use {obj.__class__.__name__}.from_db() to convert to application model." ) + # Recursively check collections + if isinstance(obj, (list, tuple)): + for i, item in enumerate(obj): + _validate_no_prisma_objects(item, f"{path}[{i}]") + elif isinstance(obj, dict): + for key, value in obj.items(): + _validate_no_prisma_objects(value, f"{path}['{key}']") -def _make_custom_serializer(model: Type[BaseModel]): - def custom_class_to_dict(obj): - data = { - "__class__": obj.__class__.__qualname__, - **obj.model_dump(), - } - logger.debug(f"Serializing {obj.__class__.__qualname__} with data: {data}") - return data - return custom_class_to_dict +P = ParamSpec("P") +R = TypeVar("R") +EXPOSED_FLAG = "__exposed__" -def _make_custom_deserializer(model: Type[BaseModel]): - def custom_dict_to_class(qualname, data: dict): - logger.debug(f"Deserializing {model.__qualname__} from data: {data}") - return model(**data) - - return custom_dict_to_class +def expose(func: C) -> C: + func = getattr(func, "__func__", func) + setattr(func, EXPOSED_FLAG, True) + return func -class AppService(AppProcess, ABC): +# -------------------------------------------------- +# AppService for IPC service based on HTTP request through FastAPI +# -------------------------------------------------- +class BaseAppService(AppProcess, ABC): shared_event_loop: asyncio.AbstractEventLoop - use_db: bool = False - use_redis: bool = False - use_supabase: bool = False - - def __init__(self): - self.uri = None @classmethod @abstractmethod @@ -125,152 +96,514 @@ def get_port(cls) -> int: @classmethod def get_host(cls) -> str: - return os.environ.get(f"{cls.service_name.upper()}_HOST", config.pyro_host) + source_host = os.environ.get(f"{get_service_name().upper()}_HOST", api_host) + target_host = os.environ.get(f"{cls.__name__.upper()}_HOST", api_host) + + if source_host == target_host and source_host != api_host: + logger.warning( + f"Service {cls.__name__} is the same host as the source service." + f"Use the localhost of {api_host} instead." + ) + return api_host + + return target_host def run_service(self) -> None: while True: time.sleep(10) - def __run_async(self, coro: Coroutine[Any, Any, T]): - return asyncio.run_coroutine_threadsafe(coro, self.shared_event_loop) - def run_and_wait(self, coro: Coroutine[Any, Any, T]) -> T: - future = self.__run_async(coro) - return future.result() + return asyncio.run_coroutine_threadsafe(coro, self.shared_event_loop).result() def run(self): self.shared_event_loop = asyncio.get_event_loop() - if self.use_db: - self.shared_event_loop.run_until_complete(db.connect()) - if self.use_redis: - redis.connect() - if self.use_supabase: - from supabase import create_client - - secrets = Secrets() - self.supabase = create_client( - secrets.supabase_url, secrets.supabase_service_role_key + + +class RemoteCallError(BaseModel): + type: str = "RemoteCallError" + args: Optional[Tuple[Any, ...]] = None + + +class UnhealthyServiceError(ValueError): + def __init__( + self, message: str = "Service is unhealthy or not ready", log: bool = True + ): + msg = f"[{get_service_name()}] - {message}" + super().__init__(msg) + self.message = msg + if log: + logger.error(self.message) + + def __str__(self): + return self.message + + +class HTTPClientError(Exception): + """Exception for HTTP client errors (4xx status codes) that should not be retried.""" + + def __init__(self, status_code: int, message: str): + self.status_code = status_code + super().__init__(f"HTTP {status_code}: {message}") + + +class HTTPServerError(Exception): + """Exception for HTTP server errors (5xx status codes) that can be retried.""" + + def __init__(self, status_code: int, message: str): + self.status_code = status_code + super().__init__(f"HTTP {status_code}: {message}") + + +EXCEPTION_MAPPING = { + e.__name__: e + for e in [ + ValueError, + RuntimeError, + TimeoutError, + ConnectionError, + UnhealthyServiceError, + HTTPClientError, + HTTPServerError, + *[ + ErrorType + for _, ErrorType in inspect.getmembers(exceptions) + if inspect.isclass(ErrorType) + and issubclass(ErrorType, Exception) + and ErrorType.__module__ == exceptions.__name__ + ], + ] +} + + +class AppService(BaseAppService, ABC): + fastapi_app: FastAPI + log_level: str = "info" + + def set_log_level(self, log_level: str): + """Set the uvicorn log level. Returns self for chaining.""" + self.log_level = log_level + return self + + @staticmethod + def _handle_internal_http_error(status_code: int = 500, log_error: bool = True): + def handler(request: Request, exc: Exception): + if log_error: + if status_code == 500: + log = logger.exception + else: + log = logger.error + log(f"{request.method} {request.url.path} failed: {exc}") + return responses.JSONResponse( + status_code=status_code, + content=RemoteCallError( + type=str(exc.__class__.__name__), + args=exc.args or (str(exc),), + ).model_dump(), ) - # Initialize the async loop. - async_thread = threading.Thread(target=self.__start_async_loop) - async_thread.daemon = True - async_thread.start() + return handler - # Initialize pyro service - daemon_thread = threading.Thread(target=self.__start_pyro) - daemon_thread.daemon = True - daemon_thread.start() + def _create_fastapi_endpoint(self, func: Callable) -> Callable: + """ + Generates a FastAPI endpoint for the given function, handling default and optional parameters. - # Run the main service (if it's not implemented, just sleep). - self.run_service() + :param func: The original function (sync/async, bound or unbound) + :return: A FastAPI endpoint function. + """ + sig = inspect.signature(func) + fields = {} + + is_bound_method = False + for name, param in sig.parameters.items(): + if name in ("self", "cls"): + is_bound_method = True + continue + + # Use the provided annotation or fallback to str if not specified + annotation = ( + param.annotation if param.annotation != inspect.Parameter.empty else str + ) + + # If a default value is provided, use it; otherwise, mark the field as required with '...' + default = param.default if param.default != inspect.Parameter.empty else ... - def cleanup(self): - if self.use_db: - logger.info(f"[{self.__class__.__name__}] ⏳ Disconnecting DB...") - self.run_and_wait(db.disconnect()) - if self.use_redis: - logger.info(f"[{self.__class__.__name__}] ⏳ Disconnecting Redis...") - redis.disconnect() - - @conn_retry("Pyro", "Starting Pyro Service") - def __start_pyro(self): - maximum_connection_thread_count = max( - Pyro5.config.THREADPOOL_SIZE, - config.num_node_workers * config.num_graph_workers, + fields[name] = (annotation, default) + + # Dynamically create a Pydantic model for the request body + RequestBodyModel = create_model("RequestBodyModel", **fields) + f = func.__get__(self) if is_bound_method else func + + if asyncio.iscoroutinefunction(f): + + async def async_endpoint(body: RequestBodyModel): # type: ignore #RequestBodyModel being variable + result = await f( + **{name: getattr(body, name) for name in type(body).model_fields} + ) + _validate_no_prisma_objects(result, f"{func.__name__} result") + return result + + return async_endpoint + else: + + def sync_endpoint(body: RequestBodyModel): # type: ignore #RequestBodyModel being variable + result = f( + **{name: getattr(body, name) for name in type(body).model_fields} + ) + _validate_no_prisma_objects(result, f"{func.__name__} result") + return result + + return sync_endpoint + + @conn_retry("FastAPI server", "Starting FastAPI server") + def __start_fastapi(self): + logger.info( + f"[{self.service_name}] Starting RPC server at http://{api_host}:{self.get_port()}" ) - Pyro5.config.THREADPOOL_SIZE = maximum_connection_thread_count # type: ignore - daemon = Pyro5.api.Daemon(host=config.pyro_host, port=self.get_port()) - self.uri = daemon.register(self, objectId=self.service_name) - logger.info(f"[{self.service_name}] Connected to Pyro; URI = {self.uri}") - daemon.requestLoop() + server = uvicorn.Server( + uvicorn.Config( + self.fastapi_app, + host=api_host, + port=self.get_port(), + log_config=None, # Explicitly None to avoid uvicorn replacing the logger. + log_level=self.log_level, + ) + ) + self.shared_event_loop.run_until_complete(server.serve()) - def __start_async_loop(self): - self.shared_event_loop.run_forever() + async def health_check(self) -> str: + """ + A method to check the health of the process. + """ + return "OK" + def run(self): + sentry_init() + super().run() + self.fastapi_app = FastAPI() + + # Register the exposed API routes. + for attr_name, attr in vars(type(self)).items(): + if getattr(attr, EXPOSED_FLAG, False): + route_path = f"/{attr_name}" + self.fastapi_app.add_api_route( + route_path, + self._create_fastapi_endpoint(attr), + methods=["POST"], + ) + self.fastapi_app.add_api_route( + "/health_check", self.health_check, methods=["POST", "GET"] + ) + self.fastapi_app.add_api_route( + "/health_check_async", self.health_check, methods=["POST", "GET"] + ) + self.fastapi_app.add_exception_handler( + ValueError, self._handle_internal_http_error(400) + ) + self.fastapi_app.add_exception_handler( + Exception, self._handle_internal_http_error(500) + ) + + # Start the FastAPI server in a separate thread. + api_thread = threading.Thread(target=self.__start_fastapi, daemon=True) + api_thread.start() -# --------- UTILITIES --------- # + # Run the main service loop (blocking). + self.run_service() +# -------------------------------------------------- +# HTTP Client utilities for dynamic service client abstraction +# -------------------------------------------------- AS = TypeVar("AS", bound=AppService) -class PyroClient: - proxy: Pyro5.api.Proxy +class AppServiceClient(ABC): + @classmethod + @abstractmethod + def get_service_type(cls) -> Type[AppService]: + pass + + def health_check(self): + pass + async def health_check_async(self): + pass -def close_service_client(client: AppService) -> None: - if isinstance(client, PyroClient): - client.proxy._pyroRelease() - else: - raise RuntimeError(f"Client {client.__class__} is not a Pyro client.") + def close(self): + pass -def get_service_client(service_type: Type[AS]) -> AS: - service_name = service_type.service_name +ASC = TypeVar("ASC", bound=AppServiceClient) + + +@conn_retry("AppService client", "Creating service client", max_retry=api_comm_retry) +def get_service_client( + service_client_type: Type[ASC], + call_timeout: int | None = api_call_timeout, + request_retry: bool = False, +) -> ASC: + + def _maybe_retry(fn: Callable[..., R]) -> Callable[..., R]: + """Decorate *fn* with tenacity retry when enabled.""" + if not request_retry: + return fn + + # Use preconfigured retry decorator for service communication + return create_retry_decorator( + max_attempts=api_comm_retry, + max_wait=5.0, + context="Service communication", + exclude_exceptions=( + # Don't retry these specific exceptions that won't be fixed by retrying + ValueError, # Invalid input/parameters + KeyError, # Missing required data + TypeError, # Wrong data types + AttributeError, # Missing attributes + asyncio.CancelledError, # Task was cancelled + concurrent.futures.CancelledError, # Future was cancelled + HTTPClientError, # HTTP 4xx client errors - don't retry + ), + )(fn) + + class DynamicClient: + def __init__(self) -> None: + service_type = service_client_type.get_service_type() + host = service_type.get_host() + port = service_type.get_port() + self.base_url = f"http://{host}:{port}".rstrip("/") + self._connection_failure_count = 0 + self._last_client_reset = 0 + + def _create_sync_client(self) -> httpx.Client: + return httpx.Client( + base_url=self.base_url, + timeout=call_timeout, + limits=httpx.Limits( + max_keepalive_connections=200, # 10x default for async concurrent calls + max_connections=500, # High limit for burst handling + keepalive_expiry=30.0, # Keep connections alive longer + ), + ) - class DynamicClient(PyroClient): - @conn_retry("Pyro", f"Connecting to [{service_name}]") - def __init__(self): - host = os.environ.get(f"{service_name.upper()}_HOST", pyro_host) - uri = f"PYRO:{service_type.service_name}@{host}:{service_type.get_port()}" - logger.debug(f"Connecting to service [{service_name}]. URI = {uri}") - self.proxy = Pyro5.api.Proxy(uri) - # Attempt to bind to ensure the connection is established - self.proxy._pyroBind() - logger.debug(f"Successfully connected to service [{service_name}]") + def _create_async_client(self) -> httpx.AsyncClient: + return httpx.AsyncClient( + base_url=self.base_url, + timeout=call_timeout, + limits=httpx.Limits( + max_keepalive_connections=200, # 10x default for async concurrent calls + max_connections=500, # High limit for burst handling + keepalive_expiry=30.0, # Keep connections alive longer + ), + ) + + @cached_property + def sync_client(self) -> httpx.Client: + return self._create_sync_client() + + @cached_property + def async_client(self) -> httpx.AsyncClient: + return self._create_async_client() + + def _handle_connection_error(self, error: Exception) -> None: + """Handle connection errors and implement self-healing""" + self._connection_failure_count += 1 + current_time = time.time() + + # If we've had 3+ failures, and it's been more than 30 seconds since last reset + if ( + self._connection_failure_count >= 3 + and current_time - self._last_client_reset > 30 + ): + + logger.warning( + f"Connection failures detected ({self._connection_failure_count}), recreating HTTP clients" + ) + + # Clear cached clients to force recreation on next access + # Only recreate when there's actually a problem + if hasattr(self, "sync_client"): + delattr(self, "sync_client") + if hasattr(self, "async_client"): + delattr(self, "async_client") + + # Reset counters + self._connection_failure_count = 0 + self._last_client_reset = current_time + + def _handle_call_method_response( + self, *, response: httpx.Response, method_name: str + ) -> Any: + try: + response.raise_for_status() + # Reset failure count on successful response + self._connection_failure_count = 0 + return response.json() + except httpx.HTTPStatusError as e: + status_code = e.response.status_code + + # Try to parse the error response as RemoteCallError for mapped exceptions + error_response = None + try: + error_response = RemoteCallError.model_validate(e.response.json()) + except Exception: + pass + + # If we successfully parsed a mapped exception type, re-raise it + if error_response and error_response.type in EXCEPTION_MAPPING: + exception_class = EXCEPTION_MAPPING[error_response.type] + args = error_response.args or [str(e)] + raise exception_class(*args) + + # Otherwise categorize by HTTP status code + if 400 <= status_code < 500: + # Client errors (4xx) - wrap to prevent retries + raise HTTPClientError(status_code, str(e)) + elif 500 <= status_code < 600: + # Server errors (5xx) - wrap but allow retries + raise HTTPServerError(status_code, str(e)) + else: + # Other status codes (1xx, 2xx, 3xx) - re-raise original error + raise e + + @_maybe_retry + def _call_method_sync(self, method_name: str, **kwargs: Any) -> Any: + try: + return self._handle_call_method_response( + method_name=method_name, + response=self.sync_client.post(method_name, json=to_dict(kwargs)), + ) + except (httpx.ConnectError, httpx.ConnectTimeout) as e: + self._handle_connection_error(e) + raise + + @_maybe_retry + async def _call_method_async(self, method_name: str, **kwargs: Any) -> Any: + try: + return self._handle_call_method_response( + method_name=method_name, + response=await self.async_client.post( + method_name, json=to_dict(kwargs) + ), + ) + except (httpx.ConnectError, httpx.ConnectTimeout) as e: + self._handle_connection_error(e) + raise + + async def aclose(self) -> None: + if hasattr(self, "sync_client"): + self.sync_client.close() + if hasattr(self, "async_client"): + await self.async_client.aclose() + + def close(self) -> None: + if hasattr(self, "sync_client"): + self.sync_client.close() + # Note: Cannot close async client synchronously + + def __del__(self): + """Cleanup HTTP clients on garbage collection to prevent resource leaks.""" + try: + if hasattr(self, "sync_client"): + self.sync_client.close() + if hasattr(self, "async_client"): + # Note: Can't await in __del__, so we just close sync + # The async client will be cleaned up by garbage collection + import warnings + + warnings.warn( + "DynamicClient async client not explicitly closed. " + "Call aclose() before destroying the client.", + ResourceWarning, + stacklevel=2, + ) + except Exception: + # Silently ignore cleanup errors in __del__ + pass + + async def __aenter__(self): + """Async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Async context manager exit.""" + await self.aclose() + + def _get_params( + self, signature: inspect.Signature, *args: Any, **kwargs: Any + ) -> dict[str, Any]: + if args: + arg_names = list(signature.parameters.keys()) + if arg_names and arg_names[0] in ("self", "cls"): + arg_names = arg_names[1:] + kwargs.update(dict(zip(arg_names, args))) + return kwargs + + def _get_return(self, expected_return: TypeAdapter | None, result: Any) -> Any: + if expected_return: + return expected_return.validate_python(result) + return result def __getattr__(self, name: str) -> Callable[..., Any]: - res = getattr(self.proxy, name) - return res + original_func = getattr(service_client_type, name, None) + if original_func is None: + raise AttributeError( + f"Method {name} not found in {service_client_type}" + ) + + rpc_name = original_func.__name__ + sig = inspect.signature(original_func) + ret_ann = sig.return_annotation + expected_return = ( + None if ret_ann is inspect.Signature.empty else TypeAdapter(ret_ann) + ) - return cast(AS, DynamicClient()) + if inspect.iscoroutinefunction(original_func): + async def async_method(*args: P.args, **kwargs: P.kwargs): + params = self._get_params(sig, *args, **kwargs) + result = await self._call_method_async(rpc_name, **params) + return self._get_return(expected_return, result) -builtin_types = [*vars(builtins).values(), NoneType, Enum] + return async_method + else: -def _pydantic_models_from_type_annotation(annotation) -> Iterator[type[BaseModel]]: - # Peel Annotated parameters - if (origin := get_origin(annotation)) and origin is Annotated: - annotation = get_args(annotation)[0] + def sync_method(*args: P.args, **kwargs: P.kwargs): + params = self._get_params(sig, *args, **kwargs) + result = self._call_method_sync(rpc_name, **params) + return self._get_return(expected_return, result) - origin = get_origin(annotation) - args = get_args(annotation) + return sync_method - if origin in ( - Union, - UnionType, - list, - List, - tuple, - Tuple, - set, - Set, - frozenset, - FrozenSet, - ): - for arg in args: - yield from _pydantic_models_from_type_annotation(arg) - elif origin in (dict, Dict): - key_type, value_type = args - yield from _pydantic_models_from_type_annotation(key_type) - yield from _pydantic_models_from_type_annotation(value_type) - elif origin in (Awaitable, Coroutine): - # For coroutines and awaitables, check the return type - return_type = args[-1] - yield from _pydantic_models_from_type_annotation(return_type) - else: - annotype = annotation if origin is None else origin - - # Exclude generic types and aliases - if ( - annotype is not None - and not hasattr(typing, getattr(annotype, "__name__", "")) - and isinstance(annotype, type) - ): - if issubclass(annotype, BaseModel): - yield annotype - elif annotype not in builtin_types and not issubclass(annotype, Enum): - raise TypeError(f"Unsupported type encountered: {annotype}") + client = cast(ASC, DynamicClient()) + + return client + + +def endpoint_to_sync( + func: Callable[Concatenate[Any, P], Awaitable[R]], +) -> Callable[Concatenate[Any, P], R]: + """ + Produce a *typed* stub that **looks** synchronous to the type‑checker. + """ + + def _stub(*args: P.args, **kwargs: P.kwargs) -> R: # pragma: no cover + raise RuntimeError("should be intercepted by __getattr__") + + update_wrapper(_stub, func) + return cast(Callable[Concatenate[Any, P], R], _stub) + + +def endpoint_to_async( + func: Callable[Concatenate[Any, P], R], +) -> Callable[Concatenate[Any, P], Awaitable[R]]: + """ + The async mirror of `to_sync`. + """ + + async def _stub(*args: P.args, **kwargs: P.kwargs) -> R: # pragma: no cover + raise RuntimeError("should be intercepted by __getattr__") + + update_wrapper(_stub, func) + return cast(Callable[Concatenate[Any, P], Awaitable[R]], _stub) diff --git a/autogpt_platform/backend/backend/util/service_test.py b/autogpt_platform/backend/backend/util/service_test.py new file mode 100644 index 000000000000..1683c642208b --- /dev/null +++ b/autogpt_platform/backend/backend/util/service_test.py @@ -0,0 +1,492 @@ +import time +from functools import cached_property +from unittest.mock import Mock + +import httpx +import pytest + +from backend.util.service import ( + AppService, + AppServiceClient, + HTTPClientError, + HTTPServerError, + endpoint_to_async, + expose, + get_service_client, +) + +TEST_SERVICE_PORT = 8765 + + +def wait_for_service_ready(service_client_type, timeout_seconds=30): + """Helper method to wait for a service to be ready using health check with retry.""" + client = get_service_client(service_client_type, request_retry=True) + client.health_check() # This will retry until service is ready + + +class ServiceTest(AppService): + def __init__(self): + super().__init__() + self.fail_count = 0 + + def cleanup(self): + pass + + @classmethod + def get_port(cls) -> int: + return TEST_SERVICE_PORT + + def __enter__(self): + # Start the service + result = super().__enter__() + + # Wait for the service to be ready + wait_for_service_ready(ServiceTestClient) + + return result + + @expose + def add(self, a: int, b: int) -> int: + return a + b + + @expose + def subtract(self, a: int, b: int) -> int: + return a - b + + @expose + def fun_with_async(self, a: int, b: int) -> int: + async def add_async(a: int, b: int) -> int: + return a + b + + return self.run_and_wait(add_async(a, b)) + + @expose + def failing_add(self, a: int, b: int) -> int: + """Method that fails 2 times then succeeds - for testing retry logic""" + self.fail_count += 1 + if self.fail_count <= 2: + raise RuntimeError(f"Intended error for testing {self.fail_count}/2") + return a + b + + @expose + def always_failing_add(self, a: int, b: int) -> int: + """Method that always fails - for testing no retry when disabled""" + raise RuntimeError("Intended error for testing") + + +class ServiceTestClient(AppServiceClient): + @classmethod + def get_service_type(cls): + return ServiceTest + + add = ServiceTest.add + subtract = ServiceTest.subtract + fun_with_async = ServiceTest.fun_with_async + failing_add = ServiceTest.failing_add + always_failing_add = ServiceTest.always_failing_add + + add_async = endpoint_to_async(ServiceTest.add) + subtract_async = endpoint_to_async(ServiceTest.subtract) + + +@pytest.mark.asyncio +async def test_service_creation(server): + with ServiceTest(): + client = get_service_client(ServiceTestClient) + assert client.add(5, 3) == 8 + assert client.subtract(10, 4) == 6 + assert client.fun_with_async(5, 3) == 8 + assert await client.add_async(5, 3) == 8 + assert await client.subtract_async(10, 4) == 6 + + +class TestDynamicClientConnectionHealing: + """Test the DynamicClient connection healing logic""" + + def setup_method(self): + """Setup for each test method""" + # Create a mock service client type + self.mock_service_type = Mock() + self.mock_service_type.get_host.return_value = "localhost" + self.mock_service_type.get_port.return_value = 8000 + + self.mock_service_client_type = Mock() + self.mock_service_client_type.get_service_type.return_value = ( + self.mock_service_type + ) + + # Create our test client with the real DynamicClient logic + self.client = self._create_test_client() + + def _create_test_client(self): + """Create a test client that mimics the real DynamicClient""" + + class TestClient: + def __init__(self, service_client_type): + service_type = service_client_type.get_service_type() + host = service_type.get_host() + port = service_type.get_port() + self.base_url = f"http://{host}:{port}".rstrip("/") + self._connection_failure_count = 0 + self._last_client_reset = 0 + + def _create_sync_client(self) -> httpx.Client: + return Mock(spec=httpx.Client) + + def _create_async_client(self) -> httpx.AsyncClient: + return Mock(spec=httpx.AsyncClient) + + @cached_property + def sync_client(self) -> httpx.Client: + return self._create_sync_client() + + @cached_property + def async_client(self) -> httpx.AsyncClient: + return self._create_async_client() + + def _handle_connection_error(self, error: Exception) -> None: + """Handle connection errors and implement self-healing""" + self._connection_failure_count += 1 + current_time = time.time() + + # If we've had 3+ failures and it's been more than 30 seconds since last reset + if ( + self._connection_failure_count >= 3 + and current_time - self._last_client_reset > 30 + ): + + # Clear cached clients to force recreation on next access + if hasattr(self, "sync_client"): + delattr(self, "sync_client") + if hasattr(self, "async_client"): + delattr(self, "async_client") + + # Reset counters + self._connection_failure_count = 0 + self._last_client_reset = current_time + + return TestClient(self.mock_service_client_type) + + def test_client_caching(self): + """Test that clients are cached via @cached_property""" + # Get clients multiple times + sync1 = self.client.sync_client + sync2 = self.client.sync_client + async1 = self.client.async_client + async2 = self.client.async_client + + # Should return same instances (cached) + assert sync1 is sync2, "Sync clients should be cached" + assert async1 is async2, "Async clients should be cached" + + def test_connection_error_counting(self): + """Test that connection errors are counted correctly""" + initial_count = self.client._connection_failure_count + + # Simulate connection errors + self.client._handle_connection_error(Exception("Connection failed")) + assert self.client._connection_failure_count == initial_count + 1 + + self.client._handle_connection_error(Exception("Connection failed")) + assert self.client._connection_failure_count == initial_count + 2 + + def test_no_reset_before_threshold(self): + """Test that clients are NOT reset before reaching failure threshold""" + # Get initial clients + sync_before = self.client.sync_client + async_before = self.client.async_client + + # Simulate 2 failures (below threshold of 3) + self.client._handle_connection_error(Exception("Connection failed")) + self.client._handle_connection_error(Exception("Connection failed")) + + # Clients should still be the same (no reset) + sync_after = self.client.sync_client + async_after = self.client.async_client + + assert ( + sync_before is sync_after + ), "Sync client should not be reset before threshold" + assert ( + async_before is async_after + ), "Async client should not be reset before threshold" + assert self.client._connection_failure_count == 2 + + def test_no_reset_within_time_window(self): + """Test that clients are NOT reset if within the 30-second window""" + # Get initial clients + sync_before = self.client.sync_client + async_before = self.client.async_client + + # Set last reset to recent time (within 30 seconds) + self.client._last_client_reset = time.time() - 10 # 10 seconds ago + + # Simulate 3+ failures + for _ in range(3): + self.client._handle_connection_error(Exception("Connection failed")) + + # Clients should still be the same (no reset due to time window) + sync_after = self.client.sync_client + async_after = self.client.async_client + + assert ( + sync_before is sync_after + ), "Sync client should not be reset within time window" + assert ( + async_before is async_after + ), "Async client should not be reset within time window" + assert self.client._connection_failure_count == 3 + + def test_reset_after_threshold_and_time(self): + """Test that clients ARE reset after threshold failures and time window""" + # Get initial clients + sync_before = self.client.sync_client + async_before = self.client.async_client + + # Set last reset to old time (beyond 30 seconds) + self.client._last_client_reset = time.time() - 60 # 60 seconds ago + + # Simulate 3+ failures to trigger reset + for _ in range(3): + self.client._handle_connection_error(Exception("Connection failed")) + + # Clients should be different (reset occurred) + sync_after = self.client.sync_client + async_after = self.client.async_client + + assert ( + sync_before is not sync_after + ), "Sync client should be reset after threshold" + assert ( + async_before is not async_after + ), "Async client should be reset after threshold" + assert ( + self.client._connection_failure_count == 0 + ), "Failure count should be reset" + + def test_reset_counters_after_healing(self): + """Test that counters are properly reset after healing""" + # Set up for reset + self.client._last_client_reset = time.time() - 60 + self.client._connection_failure_count = 5 + + # Trigger reset + self.client._handle_connection_error(Exception("Connection failed")) + + # Check counters are reset + assert self.client._connection_failure_count == 0 + assert self.client._last_client_reset > time.time() - 5 # Recently reset + + +class TestConnectionHealingIntegration: + """Integration tests for the complete connection healing workflow""" + + def test_failure_count_reset_on_success(self): + """Test that failure count would be reset on successful requests""" + + # This simulates what happens in _handle_call_method_response + class ClientWithSuccessHandling: + def __init__(self): + self._connection_failure_count = 5 + + def _handle_successful_response(self): + # This is what happens in the real _handle_call_method_response + self._connection_failure_count = 0 + + client = ClientWithSuccessHandling() + client._handle_successful_response() + assert client._connection_failure_count == 0 + + def test_thirty_second_window_timing(self): + """Test that the 30-second window works as expected""" + current_time = time.time() + + # Test cases for the timing logic + test_cases = [ + (current_time - 10, False), # 10 seconds ago - should NOT reset + (current_time - 29, False), # 29 seconds ago - should NOT reset + (current_time - 31, True), # 31 seconds ago - should reset + (current_time - 60, True), # 60 seconds ago - should reset + ] + + for last_reset_time, should_reset in test_cases: + failure_count = 3 # At threshold + time_condition = current_time - last_reset_time > 30 + should_trigger_reset = failure_count >= 3 and time_condition + + assert ( + should_trigger_reset == should_reset + ), f"Time window logic failed for {current_time - last_reset_time} seconds ago" + + +def test_cached_property_behavior(): + """Test that @cached_property works as expected for our use case""" + creation_count = 0 + + class TestCachedProperty: + @cached_property + def expensive_resource(self): + nonlocal creation_count + creation_count += 1 + return f"resource-{creation_count}" + + obj = TestCachedProperty() + + # First access should create + resource1 = obj.expensive_resource + assert creation_count == 1 + + # Second access should return cached + resource2 = obj.expensive_resource + assert creation_count == 1 # No additional creation + assert resource1 is resource2 + + # Deleting the cached property should allow recreation + delattr(obj, "expensive_resource") + resource3 = obj.expensive_resource + assert creation_count == 2 # New creation + assert resource1 != resource3 + + +def test_service_with_runtime_error_retries(server): + """Test a real service method that throws RuntimeError and gets retried""" + with ServiceTest(): + # Get client with retry enabled + client = get_service_client(ServiceTestClient, request_retry=True) + + # This should succeed after retries (fails 2 times, succeeds on 3rd try) + result = client.failing_add(5, 3) + assert result == 8 + + +def test_service_no_retry_when_disabled(server): + """Test that retry doesn't happen when disabled""" + with ServiceTest(): + # Get client with retry disabled + client = get_service_client(ServiceTestClient, request_retry=False) + + # This should fail immediately without retry + with pytest.raises(RuntimeError, match="Intended error for testing"): + client.always_failing_add(5, 3) + + +class TestHTTPErrorRetryBehavior: + """Test that HTTP client errors (4xx) are not retried but server errors (5xx) can be.""" + + # Note: These tests access private methods for testing internal behavior + # Type ignore comments are used to suppress warnings about accessing private methods + + def test_http_client_error_not_retried(self): + """Test that 4xx errors are wrapped as HTTPClientError and not retried.""" + # Create a mock response with 404 status + mock_response = Mock() + mock_response.status_code = 404 + mock_response.json.return_value = {"message": "Not found"} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "404 Not Found", request=Mock(), response=mock_response + ) + + # Create client + client = get_service_client(ServiceTestClient) + dynamic_client = client + + # Test the _handle_call_method_response directly + with pytest.raises(HTTPClientError) as exc_info: + dynamic_client._handle_call_method_response( # type: ignore[attr-defined] + response=mock_response, method_name="test_method" + ) + + assert exc_info.value.status_code == 404 + assert "404" in str(exc_info.value) + + def test_http_server_error_can_be_retried(self): + """Test that 5xx errors are wrapped as HTTPServerError and can be retried.""" + # Create a mock response with 500 status + mock_response = Mock() + mock_response.status_code = 500 + mock_response.json.return_value = {"message": "Internal server error"} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "500 Internal Server Error", request=Mock(), response=mock_response + ) + + # Create client + client = get_service_client(ServiceTestClient) + dynamic_client = client + + # Test the _handle_call_method_response directly + with pytest.raises(HTTPServerError) as exc_info: + dynamic_client._handle_call_method_response( # type: ignore[attr-defined] + response=mock_response, method_name="test_method" + ) + + assert exc_info.value.status_code == 500 + assert "500" in str(exc_info.value) + + def test_mapped_exception_preserves_original_type(self): + """Test that mapped exceptions preserve their original type regardless of HTTP status.""" + # Create a mock response with ValueError in the remote call error + mock_response = Mock() + mock_response.status_code = 400 + mock_response.json.return_value = { + "type": "ValueError", + "args": ["Invalid parameter value"], + } + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "400 Bad Request", request=Mock(), response=mock_response + ) + + # Create client + client = get_service_client(ServiceTestClient) + dynamic_client = client + + # Test the _handle_call_method_response directly + with pytest.raises(ValueError) as exc_info: + dynamic_client._handle_call_method_response( # type: ignore[attr-defined] + response=mock_response, method_name="test_method" + ) + + assert "Invalid parameter value" in str(exc_info.value) + + def test_client_error_status_codes_coverage(self): + """Test that various 4xx status codes are all wrapped as HTTPClientError.""" + client_error_codes = [400, 401, 403, 404, 405, 409, 422, 429] + + for status_code in client_error_codes: + mock_response = Mock() + mock_response.status_code = status_code + mock_response.json.return_value = {"message": f"Error {status_code}"} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + f"{status_code} Error", request=Mock(), response=mock_response + ) + + client = get_service_client(ServiceTestClient) + dynamic_client = client + + with pytest.raises(HTTPClientError) as exc_info: + dynamic_client._handle_call_method_response( # type: ignore + response=mock_response, method_name="test_method" + ) + + assert exc_info.value.status_code == status_code + + def test_server_error_status_codes_coverage(self): + """Test that various 5xx status codes are all wrapped as HTTPServerError.""" + server_error_codes = [500, 501, 502, 503, 504, 505] + + for status_code in server_error_codes: + mock_response = Mock() + mock_response.status_code = status_code + mock_response.json.return_value = {"message": f"Error {status_code}"} + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + f"{status_code} Error", request=Mock(), response=mock_response + ) + + client = get_service_client(ServiceTestClient) + dynamic_client = client + + with pytest.raises(HTTPServerError) as exc_info: + dynamic_client._handle_call_method_response( # type: ignore + response=mock_response, method_name="test_method" + ) + + assert exc_info.value.status_code == status_code diff --git a/autogpt_platform/backend/backend/util/settings.py b/autogpt_platform/backend/backend/util/settings.py index 69504f528f3c..89f13f965b62 100644 --- a/autogpt_platform/backend/backend/util/settings.py +++ b/autogpt_platform/backend/backend/util/settings.py @@ -31,12 +31,12 @@ class UpdateTrackingModel(BaseModel, Generic[T]): _updated_fields: Set[str] = PrivateAttr(default_factory=set) def __setattr__(self, name: str, value) -> None: - if name in self.model_fields: + if name in UpdateTrackingModel.model_fields: self._updated_fields.add(name) super().__setattr__(name, value) def mark_updated(self, field_name: str) -> None: - if field_name in self.model_fields: + if field_name in UpdateTrackingModel.model_fields: self._updated_fields.add(field_name) def clear_updates(self) -> None: @@ -59,12 +59,6 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): le=1000, description="Maximum number of workers to use for graph execution.", ) - num_node_workers: int = Field( - default=5, - ge=1, - le=1000, - description="Maximum number of workers to use for node execution within a single graph.", - ) pyro_host: str = Field( default="localhost", description="The default hostname of the Pyro server.", @@ -74,22 +68,78 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): description="The default timeout in seconds, for Pyro client connections.", ) pyro_client_comm_retry: int = Field( - default=3, + default=5, description="The default number of retries for Pyro client connections.", ) + rpc_client_call_timeout: int = Field( + default=300, + description="The default timeout in seconds, for RPC client calls.", + ) enable_auth: bool = Field( default=True, description="If authentication is enabled or not", ) - enable_credit: str = Field( - default="false", + enable_credit: bool = Field( + default=False, description="If user credit system is enabled or not", ) + enable_beta_monthly_credit: bool = Field( + default=True, + description="If beta monthly credits accounting is enabled or not", + ) num_user_credits_refill: int = Field( default=1500, description="Number of credits to refill for each user", ) - # Add more configuration fields as needed + refund_credit_tolerance_threshold: int = Field( + default=500, + description="Maximum number of credits above the balance to be auto-approved.", + ) + low_balance_threshold: int = Field( + default=500, + description="Credit threshold for low balance notifications (100 = $1, default 500 = $5)", + ) + refund_notification_email: str = Field( + default="refund@agpt.co", + description="Email address to send refund notifications to.", + ) + refund_request_time_key_format: str = Field( + default="%Y-%W", # This will allow for weekly refunds per user. + description="Time key format for refund requests.", + ) + execution_cost_count_threshold: int = Field( + default=100, + description="Number of executions after which the cost is calculated.", + ) + execution_cost_per_threshold: int = Field( + default=1, + description="Cost per execution in cents after each threshold.", + ) + execution_counter_expiration_time: int = Field( + default=60 * 60 * 24, + description="Time in seconds after which the execution counter is reset.", + ) + execution_late_notification_threshold_secs: int = Field( + default=5 * 60, + description="Time in seconds after which the execution stuck on QUEUED status is considered late.", + ) + execution_late_notification_checkrange_secs: int = Field( + default=60 * 60, + description="Time in seconds for how far back to check for the late executions.", + ) + + block_error_rate_threshold: float = Field( + default=0.5, + description="Error rate threshold (0.0-1.0) for triggering block error alerts.", + ) + block_error_rate_check_interval_secs: int = Field( + default=24 * 60 * 60, # 24 hours + description="Interval in seconds between block error rate checks.", + ) + block_error_include_top_blocks: int = Field( + default=3, + description="Number of top blocks with most errors to show when no blocks exceed threshold (0 to disable).", + ) model_config = SettingsConfigDict( env_file=".env", @@ -136,6 +186,16 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): description="The port for agent server API to run on", ) + notification_service_port: int = Field( + default=8007, + description="The port for notification service daemon to run on", + ) + + otto_api_url: str = Field( + default="", + description="The URL for the Otto API service", + ) + platform_base_url: str = Field( default="", description="Must be set so the application knows where it's hosted at. " @@ -153,6 +213,122 @@ class Config(UpdateTrackingModel["Config"], BaseSettings): description="The name of the Google Cloud Storage bucket for media files", ) + reddit_user_agent: str = Field( + default="AutoGPT:1.0 (by /u/autogpt)", + description="The user agent for the Reddit API", + ) + + scheduler_db_pool_size: int = Field( + default=3, + description="The pool size for the scheduler database connection pool", + ) + + rabbitmq_host: str = Field( + default="localhost", + description="The host for the RabbitMQ server", + ) + rabbitmq_port: int = Field( + default=5672, + description="The port for the RabbitMQ server", + ) + + rabbitmq_vhost: str = Field( + default="/", + description="The vhost for the RabbitMQ server", + ) + + postmark_sender_email: str = Field( + default="invalid@invalid.com", + description="The email address to use for sending emails", + ) + + use_agent_image_generation_v2: bool = Field( + default=True, + description="Whether to use the new agent image generation service", + ) + enable_agent_input_subtype_blocks: bool = Field( + default=True, + description="Whether to enable the agent input subtype blocks", + ) + platform_alert_discord_channel: str = Field( + default="local-alerts", + description="The Discord channel for the platform", + ) + product_alert_discord_channel: str = Field( + default="product-alerts", + description="The Discord channel for product alerts (low balance, zero balance, etc.)", + ) + + clamav_service_host: str = Field( + default="localhost", + description="The host for the ClamAV daemon", + ) + clamav_service_port: int = Field( + default=3310, + description="The port for the ClamAV daemon", + ) + clamav_service_timeout: int = Field( + default=60, + description="The timeout in seconds for the ClamAV daemon", + ) + clamav_service_enabled: bool = Field( + default=True, + description="Whether virus scanning is enabled or not", + ) + clamav_max_concurrency: int = Field( + default=10, + description="The maximum number of concurrent scans to perform", + ) + clamav_mark_failed_scans_as_clean: bool = Field( + default=False, + description="Whether to mark failed scans as clean or not", + ) + + enable_example_blocks: bool = Field( + default=False, + description="Whether to enable example blocks in production", + ) + + cloud_storage_cleanup_interval_hours: int = Field( + default=6, + ge=1, + le=24, + description="Hours between cloud storage cleanup runs (1-24 hours)", + ) + + upload_file_size_limit_mb: int = Field( + default=256, + ge=1, + le=1024, + description="Maximum file size in MB for file uploads (1-1024 MB)", + ) + + # AutoMod configuration + automod_enabled: bool = Field( + default=False, + description="Whether AutoMod content moderation is enabled", + ) + automod_api_url: str = Field( + default="", + description="AutoMod API base URL - Make sure it ends in /api", + ) + automod_timeout: int = Field( + default=30, + description="Timeout in seconds for AutoMod API requests", + ) + automod_retry_attempts: int = Field( + default=3, + description="Number of retry attempts for AutoMod API requests", + ) + automod_retry_delay: float = Field( + default=1.0, + description="Delay between retries for AutoMod API requests in seconds", + ) + automod_fail_open: bool = Field( + default=False, + description="If True, allow execution to continue if AutoMod fails", + ) + @field_validator("platform_base_url", "frontend_base_url") @classmethod def validate_platform_base_url(cls, v: str, info: ValidationInfo) -> str: @@ -187,7 +363,12 @@ def validate_platform_base_url(cls, v: str, info: ValidationInfo) -> str: description="A whitelist of trusted internal endpoints for the backend to make requests to.", ) - backend_cors_allow_origins: List[str] = Field(default_factory=list) + max_message_size_limit: int = Field( + default=16 * 1024 * 1024, # 16 MB + description="Maximum message size limit for communication with the message bus", + ) + + backend_cors_allow_origins: List[str] = Field(default=["http://localhost:3000"]) @field_validator("backend_cors_allow_origins") @classmethod @@ -244,6 +425,35 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings): encryption_key: str = Field(default="", description="Encryption key") + rabbitmq_default_user: str = Field(default="", description="RabbitMQ default user") + rabbitmq_default_pass: str = Field( + default="", description="RabbitMQ default password" + ) + + postmark_server_api_token: str = Field( + default="", description="Postmark server API token used for sending emails" + ) + + postmark_webhook_token: str = Field( + default="", + description="The token to use for the Postmark webhook", + ) + + unsubscribe_secret_key: str = Field( + default="", + description="The secret key to use for the unsubscribe user by token", + ) + + # Cloudflare Turnstile credentials + turnstile_secret_key: str = Field( + default="", + description="Cloudflare Turnstile backend secret key", + ) + turnstile_verify_url: str = Field( + default="https://challenges.cloudflare.com/turnstile/v0/siteverify", + description="Cloudflare Turnstile verify URL", + ) + # OAuth server credentials for integrations # --8<-- [start:OAuthServerCredentialsExample] github_client_id: str = Field(default="", description="GitHub OAuth client ID") @@ -259,16 +469,25 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings): notion_client_secret: str = Field( default="", description="Notion OAuth client secret" ) + twitter_client_id: str = Field(default="", description="Twitter/X OAuth client ID") + twitter_client_secret: str = Field( + default="", description="Twitter/X OAuth client secret" + ) + discord_client_id: str = Field(default="", description="Discord OAuth client ID") + discord_client_secret: str = Field( + default="", description="Discord OAuth client secret" + ) openai_api_key: str = Field(default="", description="OpenAI API key") + aiml_api_key: str = Field(default="", description="'AI/ML API' key") anthropic_api_key: str = Field(default="", description="Anthropic API key") groq_api_key: str = Field(default="", description="Groq API key") open_router_api_key: str = Field(default="", description="Open Router API Key") + llama_api_key: str = Field(default="", description="Llama API Key") + v0_api_key: str = Field(default="", description="v0 by Vercel API key") reddit_client_id: str = Field(default="", description="Reddit client ID") reddit_client_secret: str = Field(default="", description="Reddit client secret") - reddit_username: str = Field(default="", description="Reddit username") - reddit_password: str = Field(default="", description="Reddit password") openweathermap_api_key: str = Field( default="", description="OpenWeatherMap API key" @@ -295,10 +514,40 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings): jina_api_key: str = Field(default="", description="Jina API Key") unreal_speech_api_key: str = Field(default="", description="Unreal Speech API Key") - fal_key: str = Field(default="", description="FAL API key") + fal_api_key: str = Field(default="", description="FAL API key") + exa_api_key: str = Field(default="", description="Exa API key") + e2b_api_key: str = Field(default="", description="E2B API key") + nvidia_api_key: str = Field(default="", description="Nvidia API key") + mem0_api_key: str = Field(default="", description="Mem0 API key") - # Add more secret fields as needed + linear_client_id: str = Field(default="", description="Linear client ID") + linear_client_secret: str = Field(default="", description="Linear client secret") + + todoist_client_id: str = Field(default="", description="Todoist client ID") + todoist_client_secret: str = Field(default="", description="Todoist client secret") + + stripe_api_key: str = Field(default="", description="Stripe API Key") + stripe_webhook_secret: str = Field(default="", description="Stripe Webhook Secret") + + screenshotone_api_key: str = Field(default="", description="ScreenshotOne API Key") + apollo_api_key: str = Field(default="", description="Apollo API Key") + smartlead_api_key: str = Field(default="", description="SmartLead API Key") + zerobounce_api_key: str = Field(default="", description="ZeroBounce API Key") + enrichlayer_api_key: str = Field(default="", description="Enrichlayer API Key") + + # AutoMod API credentials + automod_api_key: str = Field(default="", description="AutoMod API key") + + # LaunchDarkly feature flags + launch_darkly_sdk_key: str = Field( + default="", + description="The LaunchDarkly SDK key for feature flag management", + ) + + ayrshare_api_key: str = Field(default="", description="Ayrshare API Key") + ayrshare_jwt_key: str = Field(default="", description="Ayrshare private Key") + # Add more secret fields as needed model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/autogpt_platform/backend/backend/util/test.py b/autogpt_platform/backend/backend/util/test.py index 5433fd90226c..e2e4cc79b028 100644 --- a/autogpt_platform/backend/backend/util/test.py +++ b/autogpt_platform/backend/backend/util/test.py @@ -1,13 +1,21 @@ +import inspect import logging import time -from typing import Sequence +import uuid +from typing import Sequence, cast from backend.data import db -from backend.data.block import Block, initialize_blocks -from backend.data.execution import ExecutionResult, ExecutionStatus -from backend.data.model import CREDENTIALS_FIELD_NAME +from backend.data.block import Block, BlockSchema, initialize_blocks +from backend.data.execution import ( + ExecutionStatus, + NodeExecutionResult, + UserContext, + get_graph_execution, +) +from backend.data.model import _BaseCredentials from backend.data.user import create_default_user -from backend.executor import DatabaseManager, ExecutionManager, ExecutionScheduler +from backend.executor import DatabaseManager, ExecutionManager, Scheduler +from backend.notifications.notifications import NotificationManager from backend.server.rest_api import AgentServer from backend.server.utils import get_user_id @@ -19,7 +27,8 @@ def __init__(self): self.db_api = DatabaseManager() self.exec_manager = ExecutionManager() self.agent_server = AgentServer() - self.scheduler = ExecutionScheduler() + self.scheduler = Scheduler(register_system_tasks=False) + self.notif_manager = NotificationManager() @staticmethod def test_get_user_id(): @@ -31,6 +40,7 @@ async def __aenter__(self): self.agent_server.__enter__() self.exec_manager.__enter__() self.scheduler.__enter__() + self.notif_manager.__enter__() await db.connect() await initialize_blocks() @@ -45,6 +55,7 @@ async def __aexit__(self, exc_type, exc_val, exc_tb): self.exec_manager.__exit__(exc_type, exc_val, exc_tb) self.agent_server.__exit__(exc_type, exc_val, exc_tb) self.db_api.__exit__(exc_type, exc_val, exc_tb) + self.notif_manager.__exit__(exc_type, exc_val, exc_tb) def setup_dependency_overrides(self): # Override get_user_id for testing @@ -55,30 +66,36 @@ def setup_dependency_overrides(self): async def wait_execution( user_id: str, - graph_id: str, graph_exec_id: str, - timeout: int = 20, -) -> Sequence[ExecutionResult]: + timeout: int = 30, +) -> Sequence[NodeExecutionResult]: async def is_execution_completed(): status = await AgentServer().test_get_graph_run_status(graph_exec_id, user_id) log.info(f"Execution status: {status}") if status == ExecutionStatus.FAILED: log.info("Execution failed") raise Exception("Execution failed") + if status == ExecutionStatus.TERMINATED: + log.info("Execution terminated") + raise Exception("Execution terminated") return status == ExecutionStatus.COMPLETED # Wait for the executions to complete for i in range(timeout): if await is_execution_completed(): - return await AgentServer().test_get_graph_run_node_execution_results( - graph_id, graph_exec_id, user_id + graph_exec = await get_graph_execution( + user_id=user_id, + execution_id=graph_exec_id, + include_node_executions=True, ) + assert graph_exec, f"Graph execution #{graph_exec_id} not found" + return graph_exec.node_executions time.sleep(1) assert False, "Execution did not complete in time." -def execute_block_test(block: Block): +async def execute_block_test(block: Block): prefix = f"[Test-{block.name}]" if not block.test_input or not block.test_output: @@ -95,26 +112,60 @@ def execute_block_test(block: Block): for mock_name, mock_obj in (block.test_mock or {}).items(): log.info(f"{prefix} mocking {mock_name}...") - if hasattr(block, mock_name): - setattr(block, mock_name, mock_obj) - else: + # check whether the field mock_name is an async function or not + if not hasattr(block, mock_name): log.info(f"{prefix} mock {mock_name} not found in block") + continue - extra_exec_kwargs = {} + fun = getattr(block, mock_name) + is_async = inspect.iscoroutinefunction(fun) or inspect.isasyncgenfunction(fun) - if CREDENTIALS_FIELD_NAME in block.input_schema.model_fields: - if not block.test_credentials: - raise ValueError( - f"{prefix} requires credentials but has no test_credentials" - ) - extra_exec_kwargs[CREDENTIALS_FIELD_NAME] = block.test_credentials + if is_async: + + async def async_mock( + *args, _mock_name=mock_name, _mock_obj=mock_obj, **kwargs + ): + return _mock_obj(*args, **kwargs) + + setattr(block, mock_name, async_mock) + + else: + setattr(block, mock_name, mock_obj) + + # Populate credentials argument(s) + extra_exec_kwargs: dict = { + "graph_id": str(uuid.uuid4()), + "node_id": str(uuid.uuid4()), + "graph_exec_id": str(uuid.uuid4()), + "node_exec_id": str(uuid.uuid4()), + "user_id": str(uuid.uuid4()), + "user_context": UserContext(timezone="UTC"), # Default for tests + } + input_model = cast(type[BlockSchema], block.input_schema) + credentials_input_fields = input_model.get_credentials_fields() + if len(credentials_input_fields) == 1 and isinstance( + block.test_credentials, _BaseCredentials + ): + field_name = next(iter(credentials_input_fields)) + extra_exec_kwargs[field_name] = block.test_credentials + elif credentials_input_fields and block.test_credentials: + if not isinstance(block.test_credentials, dict): + raise TypeError(f"Block {block.name} has no usable test credentials") + else: + for field_name in credentials_input_fields: + if field_name in block.test_credentials: + extra_exec_kwargs[field_name] = block.test_credentials[field_name] for input_data in block.test_input: log.info(f"{prefix} in: {input_data}") - for output_name, output_data in block.execute(input_data, **extra_exec_kwargs): + async for output_name, output_data in block.execute( + input_data, **extra_exec_kwargs + ): if output_index >= len(block.test_output): - raise ValueError(f"{prefix} produced output more than expected") + raise ValueError( + f"{prefix} produced output more than expected {output_index} >= {len(block.test_output)}:\nOutput Expected:\t\t{block.test_output}\nFailed Output Produced:\t('{output_name}', {output_data})\nNote that this may not be the one that was unexpected, but it is the first that triggered the extra output warning" + ) ex_output_name, ex_output_data = block.test_output[output_index] def compare(data, expected_data): @@ -131,7 +182,9 @@ def compare(data, expected_data): log.info(f"{prefix} {mark} comparing `{data}` vs `{expected_data}`") if not is_matching: raise ValueError( - f"{prefix}: wrong output {data} vs {expected_data}" + f"{prefix}: wrong output {data} vs {expected_data}\n" + f"Output Expected:\t\t{block.test_output}\n" + f"Failed Output Produced:\t('{output_name}', {output_data})" ) compare(output_data, ex_output_data) diff --git a/autogpt_platform/backend/backend/util/test_json.py b/autogpt_platform/backend/backend/util/test_json.py new file mode 100644 index 000000000000..9e8602ea0868 --- /dev/null +++ b/autogpt_platform/backend/backend/util/test_json.py @@ -0,0 +1,217 @@ +import datetime +from typing import Any, Optional + +from prisma import Json +from pydantic import BaseModel + +from backend.util.json import SafeJson + + +class SamplePydanticModel(BaseModel): + name: str + age: Optional[int] = None + timestamp: Optional[datetime.datetime] = None + metadata: Optional[dict] = None + + +class SampleModelWithNonSerializable(BaseModel): + name: str + func: Any = None # Could contain non-serializable data + data: Optional[dict] = None + + +class TestSafeJson: + """Test cases for SafeJson function.""" + + def test_safejson_returns_json_type(self): + """Test that SafeJson returns a proper Json instance.""" + data = {"test": "value"} + result = SafeJson(data) + assert isinstance(result, Json) + + def test_simple_dict_serialization(self): + """Test basic dictionary serialization.""" + data = {"name": "John", "age": 30, "active": True} + result = SafeJson(data) + assert isinstance(result, Json) + + def test_unicode_handling(self): + """Test that Unicode characters are handled properly.""" + data = { + "name": "café", + "emoji": "🎉", + "chinese": "你好", + "arabic": "مرحبا", + } + result = SafeJson(data) + assert isinstance(result, Json) + + def test_nested_data_structures(self): + """Test complex nested data structures.""" + data = { + "user": { + "name": "Alice", + "preferences": { + "theme": "dark", + "notifications": ["email", "push"], + }, + }, + "metadata": { + "tags": ["important", "urgent"], + "scores": [8.5, 9.2, 7.8], + }, + } + result = SafeJson(data) + assert isinstance(result, Json) + + def test_pydantic_model_basic(self): + """Test basic Pydantic model serialization.""" + model = SamplePydanticModel(name="John", age=30) + result = SafeJson(model) + assert isinstance(result, Json) + + def test_pydantic_model_with_none_values(self): + """Test Pydantic model with None values (should be excluded).""" + model = SamplePydanticModel(name="John", age=None, timestamp=None) + result = SafeJson(model) + assert isinstance(result, Json) + # The actual Json content should exclude None values due to exclude_none=True + + def test_pydantic_model_with_datetime(self): + """Test Pydantic model with datetime field.""" + now = datetime.datetime.now() + model = SamplePydanticModel(name="John", age=25, timestamp=now) + result = SafeJson(model) + assert isinstance(result, Json) + + def test_non_serializable_values_in_dict(self): + """Test that non-serializable values in dict are converted to None.""" + data = { + "name": "test", + "function": lambda x: x, # Non-serializable + "datetime": datetime.datetime.now(), # Non-serializable + "valid_data": "this should work", + } + result = SafeJson(data) + assert isinstance(result, Json) + + def test_pydantic_model_with_non_serializable_fallback(self): + """Test Pydantic model with non-serializable field using fallback.""" + model = SampleModelWithNonSerializable( + name="test", + func=lambda x: x, # Non-serializable + data={"valid": "data"}, + ) + result = SafeJson(model) + assert isinstance(result, Json) + + def test_empty_data_structures(self): + """Test empty data structures.""" + test_cases = [ + {}, # Empty dict + [], # Empty list + "", # Empty string + None, # None value + ] + + for data in test_cases: + result = SafeJson(data) + assert isinstance(result, Json) + + def test_complex_mixed_data(self): + """Test complex mixed data with various types.""" + data = { + "string": "test", + "integer": 42, + "float": 3.14, + "boolean": True, + "none_value": None, + "list": [1, 2, "three", {"nested": "dict"}], + "nested_dict": { + "level2": { + "level3": ["deep", "nesting", 123], + } + }, + } + result = SafeJson(data) + assert isinstance(result, Json) + + def test_list_of_pydantic_models(self): + """Test list containing Pydantic models.""" + models = [ + SamplePydanticModel(name="Alice", age=25), + SamplePydanticModel(name="Bob", age=30), + ] + data = {"users": models} + result = SafeJson(data) + assert isinstance(result, Json) + + def test_edge_case_circular_reference_protection(self): + """Test that circular references don't cause infinite loops.""" + # Note: This test assumes the underlying json.dumps handles circular refs + # by raising an exception, which our fallback should handle + data = {} + data["self"] = data # Create circular reference + + # This should either work with fallback or raise a reasonable error + try: + result = SafeJson(data) + assert isinstance(result, Json) + except (ValueError, RecursionError): + # If it raises an error, that's also acceptable behavior + pass + + def test_large_data_structure(self): + """Test with a reasonably large data structure.""" + data = { + "items": [ + {"id": i, "name": f"item_{i}", "active": i % 2 == 0} for i in range(100) + ], + "metadata": { + "total": 100, + "generated_at": "2024-01-01T00:00:00Z", + "tags": ["auto", "generated", "test"], + }, + } + result = SafeJson(data) + assert isinstance(result, Json) + + def test_special_characters_and_encoding(self): + """Test various special characters and encoding scenarios.""" + data = { + "quotes": 'He said "Hello world!"', + "backslashes": "C:\\Users\\test\\file.txt", + "newlines": "Line 1\nLine 2\nLine 3", + "tabs": "Column1\tColumn2\tColumn3", + "unicode_escape": "\u0048\u0065\u006c\u006c\u006f", # "Hello" + "mixed": "Test with émojis 🚀 and ñúméríçs", + } + result = SafeJson(data) + assert isinstance(result, Json) + + def test_numeric_edge_cases(self): + """Test various numeric edge cases.""" + data = { + "zero": 0, + "negative": -42, + "large_int": 999999999999999999, + "small_float": 0.000001, + "large_float": 1e10, + "infinity": float("inf"), # This might become None due to fallback + "negative_infinity": float( + "-inf" + ), # This might become None due to fallback + } + result = SafeJson(data) + assert isinstance(result, Json) + + def test_boolean_and_null_values(self): + """Test boolean and null value handling.""" + data = { + "true_value": True, + "false_value": False, + "null_value": None, + "mixed_list": [True, False, None, "string", 42], + } + result = SafeJson(data) + assert isinstance(result, Json) diff --git a/autogpt_platform/backend/backend/util/text.py b/autogpt_platform/backend/backend/util/text.py index 951339b55d59..0c1e22edc0af 100644 --- a/autogpt_platform/backend/backend/util/text.py +++ b/autogpt_platform/backend/backend/util/text.py @@ -1,22 +1,152 @@ -import re +import logging +import bleach +from bleach.css_sanitizer import CSSSanitizer from jinja2 import BaseLoader from jinja2.sandbox import SandboxedEnvironment +from markupsafe import Markup + +logger = logging.getLogger(__name__) + + +def format_filter_for_jinja2(value, format_string=None): + if format_string: + return format_string % float(value) + return value class TextFormatter: def __init__(self): - # Create a sandboxed environment self.env = SandboxedEnvironment(loader=BaseLoader(), autoescape=True) - - # Clear any registered filters, tests, and globals to minimize attack surface - self.env.filters.clear() - self.env.tests.clear() self.env.globals.clear() + # Instead of clearing all filters, just remove potentially unsafe ones + unsafe_filters = ["pprint", "tojson", "urlize", "xmlattr"] + for f in unsafe_filters: + if f in self.env.filters: + del self.env.filters[f] + + self.env.filters["format"] = format_filter_for_jinja2 + + # Define allowed CSS properties (sorted alphabetically, if you add more) + allowed_css_properties = [ + "background-color", + "border", + "border-bottom", + "border-color", + "border-left", + "border-radius", + "border-right", + "border-style", + "border-top", + "border-width", + "bottom", + "box-shadow", + "clear", + "color", + "display", + "float", + "font-family", + "font-size", + "font-weight", + "height", + "left", + "letter-spacing", + "line-height", + "margin-bottom", + "margin-left", + "margin-right", + "margin-top", + "padding", + "position", + "right", + "text-align", + "text-decoration", + "text-shadow", + "text-transform", + "top", + "width", + ] + + self.css_sanitizer = CSSSanitizer(allowed_css_properties=allowed_css_properties) + + # Define allowed tags (sorted alphabetically, if you add more) + self.allowed_tags = [ + "a", + "b", + "br", + "div", + "em", + "h1", + "h2", + "h3", + "h4", + "h5", + "i", + "img", + "li", + "p", + "span", + "strong", + "u", + "ul", + ] + + # Define allowed attributes to be used on specific tags + self.allowed_attributes = { + "*": ["class", "style"], + "a": ["href"], + "img": ["src"], + } + def format_string(self, template_str: str, values=None, **kwargs) -> str: - # For python.format compatibility: replace all {...} with {{..}}. - # But avoid replacing {{...}} to {{{...}}}. - template_str = re.sub(r"(?}", template_str) + """Regular template rendering with escaping""" template = self.env.from_string(template_str) return template.render(values or {}, **kwargs) + + def format_email( + self, + subject_template: str, + base_template: str, + content_template: str, + data=None, + **kwargs, + ) -> tuple[str, str]: + """ + Special handling for email templates where content needs to be rendered as HTML + """ + # First render the content template + content = self.format_string(content_template, data, **kwargs) + + # Clean the HTML + CSS but don't escape it + clean_content = bleach.clean( + content, + tags=self.allowed_tags, + attributes=self.allowed_attributes, + css_sanitizer=self.css_sanitizer, + strip=True, + ) + + # Mark the cleaned HTML as safe using Markup + safe_content = Markup(clean_content) + + # Render subject + rendered_subject_template = self.format_string(subject_template, data, **kwargs) + + # Create new env just for HTML template + html_env = SandboxedEnvironment(loader=BaseLoader(), autoescape=True) + html_env.filters["safe"] = lambda x: ( + x if isinstance(x, Markup) else Markup(str(x)) + ) + + # Render base template with the safe content + template = html_env.from_string(base_template) + rendered_base_template = template.render( + data={ + "message": safe_content, + "title": rendered_subject_template, + "unsubscribe_link": kwargs.get("unsubscribe_link", ""), + } + ) + + return rendered_subject_template, rendered_base_template diff --git a/autogpt_platform/backend/backend/util/timezone_name.py b/autogpt_platform/backend/backend/util/timezone_name.py new file mode 100644 index 000000000000..1bd4caf1960a --- /dev/null +++ b/autogpt_platform/backend/backend/util/timezone_name.py @@ -0,0 +1,115 @@ +""" +Time zone name validation and serialization. + +This file is adapted from pydantic-extra-types: +https://github.com/pydantic/pydantic-extra-types/blob/main/pydantic_extra_types/timezone_name.py + +The MIT License (MIT) + +Copyright (c) 2023 Samuel Colvin and other contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Modifications: +- Modified to always use pytz for timezone data to ensure consistency across environments +- Removed zoneinfo support to prevent environment-specific timezone lists +""" + +from __future__ import annotations + +from typing import Any, Callable, cast + +import pytz +from pydantic import GetCoreSchemaHandler, GetJsonSchemaHandler +from pydantic_core import PydanticCustomError, core_schema + +# Cache the timezones at module level to avoid repeated computation +ALL_TIMEZONES: set[str] = set(pytz.all_timezones) + + +def get_timezones() -> set[str]: + """Get timezones from pytz for consistency across all environments.""" + # Return cached timezone set + return ALL_TIMEZONES + + +class TimeZoneNameSettings(type): + def __new__( + cls, name: str, bases: tuple[type, ...], dct: dict[str, Any], **kwargs: Any + ) -> type[TimeZoneName]: + dct["strict"] = kwargs.pop("strict", True) + return cast("type[TimeZoneName]", super().__new__(cls, name, bases, dct)) + + def __init__( + cls, name: str, bases: tuple[type, ...], dct: dict[str, Any], **kwargs: Any + ) -> None: + super().__init__(name, bases, dct) + cls.strict = kwargs.get("strict", True) + + +def timezone_name_settings( + **kwargs: Any, +) -> Callable[[type[TimeZoneName]], type[TimeZoneName]]: + def wrapper(cls: type[TimeZoneName]) -> type[TimeZoneName]: + cls.strict = kwargs.get("strict", True) + return cls + + return wrapper + + +@timezone_name_settings(strict=True) +class TimeZoneName(str): + """TimeZoneName is a custom string subclass for validating and serializing timezone names.""" + + __slots__: list[str] = [] + allowed_values: set[str] = set(get_timezones()) + allowed_values_list: list[str] = sorted(allowed_values) + allowed_values_upper_to_correct: dict[str, str] = { + val.upper(): val for val in allowed_values + } + strict: bool + + @classmethod + def _validate( + cls, __input_value: str, _: core_schema.ValidationInfo + ) -> TimeZoneName: + if __input_value not in cls.allowed_values: + if not cls.strict: + upper_value = __input_value.strip().upper() + if upper_value in cls.allowed_values_upper_to_correct: + return cls(cls.allowed_values_upper_to_correct[upper_value]) + raise PydanticCustomError("TimeZoneName", "Invalid timezone name.") + return cls(__input_value) + + @classmethod + def __get_pydantic_core_schema__( + cls, _: type[Any], __: GetCoreSchemaHandler + ) -> core_schema.AfterValidatorFunctionSchema: + return core_schema.with_info_after_validator_function( + cls._validate, + core_schema.str_schema(min_length=1), + ) + + @classmethod + def __get_pydantic_json_schema__( + cls, schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> dict[str, Any]: + json_schema = handler(schema) + json_schema.update({"enum": cls.allowed_values_list}) + return json_schema diff --git a/autogpt_platform/backend/backend/util/timezone_utils.py b/autogpt_platform/backend/backend/util/timezone_utils.py new file mode 100644 index 000000000000..6a6c438085e4 --- /dev/null +++ b/autogpt_platform/backend/backend/util/timezone_utils.py @@ -0,0 +1,148 @@ +""" +Timezone conversion utilities for API endpoints. +Handles conversion between user timezones and UTC for scheduler operations. +""" + +import logging +from datetime import datetime +from typing import Optional +from zoneinfo import ZoneInfo + +from croniter import croniter + +logger = logging.getLogger(__name__) + + +def convert_cron_to_utc(cron_expr: str, user_timezone: str) -> str: + """ + Convert a cron expression from user timezone to UTC. + + NOTE: This is a simplified conversion that only adjusts minute and hour fields. + Complex cron expressions with specific day/month/weekday patterns may not + convert accurately due to timezone offset variations throughout the year. + + Args: + cron_expr: Cron expression in user timezone + user_timezone: User's IANA timezone identifier + + Returns: + Cron expression adjusted for UTC execution + + Raises: + ValueError: If timezone or cron expression is invalid + """ + try: + user_tz = ZoneInfo(user_timezone) + utc_tz = ZoneInfo("UTC") + + # Split the cron expression into its five fields + cron_fields = cron_expr.strip().split() + if len(cron_fields) != 5: + raise ValueError( + "Cron expression must have 5 fields (minute hour day month weekday)" + ) + + # Get the current time in the user's timezone + now_user = datetime.now(user_tz) + + # Get the next scheduled time in user timezone + cron = croniter(cron_expr, now_user) + next_user_time = cron.get_next(datetime) + + # Convert to UTC + next_utc_time = next_user_time.astimezone(utc_tz) + + # Adjust minute and hour fields for UTC, keep day/month/weekday as in original + utc_cron_parts = [ + str(next_utc_time.minute), + str(next_utc_time.hour), + cron_fields[2], # day of month + cron_fields[3], # month + cron_fields[4], # day of week + ] + + utc_cron = " ".join(utc_cron_parts) + + logger.debug( + f"Converted cron '{cron_expr}' from {user_timezone} to UTC: '{utc_cron}'" + ) + return utc_cron + + except Exception as e: + logger.error( + f"Failed to convert cron expression '{cron_expr}' from {user_timezone} to UTC: {e}" + ) + raise ValueError(f"Invalid cron expression or timezone: {e}") + + +def convert_utc_time_to_user_timezone(utc_time_str: str, user_timezone: str) -> str: + """ + Convert a UTC datetime string to user timezone. + + Args: + utc_time_str: ISO format datetime string in UTC + user_timezone: User's IANA timezone identifier + + Returns: + ISO format datetime string in user timezone + """ + try: + # Parse the time string + parsed_time = datetime.fromisoformat(utc_time_str.replace("Z", "+00:00")) + + user_tz = ZoneInfo(user_timezone) + + # If the time already has timezone info, convert it to user timezone + if parsed_time.tzinfo is not None: + # Convert to user timezone regardless of source timezone + user_time = parsed_time.astimezone(user_tz) + return user_time.isoformat() + + # If no timezone info, treat as UTC and convert to user timezone + parsed_time = parsed_time.replace(tzinfo=ZoneInfo("UTC")) + user_time = parsed_time.astimezone(user_tz) + return user_time.isoformat() + + except Exception as e: + logger.error( + f"Failed to convert UTC time '{utc_time_str}' to {user_timezone}: {e}" + ) + # Return original time if conversion fails + return utc_time_str + + +def validate_timezone(timezone: str) -> bool: + """ + Validate if a timezone string is a valid IANA timezone identifier. + + Args: + timezone: Timezone string to validate + + Returns: + True if valid, False otherwise + """ + try: + ZoneInfo(timezone) + return True + except Exception: + return False + + +def get_user_timezone_or_utc(user_timezone: Optional[str]) -> str: + """ + Get user timezone or default to UTC if invalid/missing. + + Args: + user_timezone: User's timezone preference + + Returns: + Valid timezone string (user's preference or UTC fallback) + """ + if not user_timezone or user_timezone == "not-set": + return "UTC" + + if validate_timezone(user_timezone): + return user_timezone + + logger.warning(f"Invalid user timezone '{user_timezone}', falling back to UTC") + return "UTC" diff --git a/autogpt_platform/backend/backend/util/truncate.py b/autogpt_platform/backend/backend/util/truncate.py new file mode 100644 index 000000000000..cf7de9f8feb7 --- /dev/null +++ b/autogpt_platform/backend/backend/util/truncate.py @@ -0,0 +1,128 @@ +import sys +from typing import Any + +# --------------------------------------------------------------------------- +# String helpers +# --------------------------------------------------------------------------- + + +def _truncate_string_middle(value: str, limit: int) -> str: + """Shorten *value* to *limit* chars by removing the **middle** portion.""" + + if len(value) <= limit: + return value + + head_len = max(1, limit // 2) + tail_len = limit - head_len # ensures total == limit + omitted = len(value) - (head_len + tail_len) + return f"{value[:head_len]}… (omitted {omitted} chars)…{value[-tail_len:]}" + + +# --------------------------------------------------------------------------- +# List helpers +# --------------------------------------------------------------------------- + + +def _truncate_list_middle(lst: list[Any], str_lim: int, list_lim: int) -> list[Any]: + """Return *lst* truncated to *list_lim* items, removing from the middle. + + Each retained element is itself recursively truncated via + :func:`_truncate_value` so we don’t blow the budget with long strings nested + inside. + """ + + if len(lst) <= list_lim: + return [_truncate_value(v, str_lim, list_lim) for v in lst] + + # If the limit is very small (<3) fall back to head‑only + sentinel to avoid + # degenerate splits. + if list_lim < 3: + kept = [_truncate_value(v, str_lim, list_lim) for v in lst[:list_lim]] + kept.append(f"… (omitted {len(lst) - list_lim} items)…") + return kept + + head_len = list_lim // 2 + tail_len = list_lim - head_len + + head = [_truncate_value(v, str_lim, list_lim) for v in lst[:head_len]] + tail = [_truncate_value(v, str_lim, list_lim) for v in lst[-tail_len:]] + + omitted = len(lst) - (head_len + tail_len) + sentinel = f"… (omitted {omitted} items)…" + return head + [sentinel] + tail + + +# --------------------------------------------------------------------------- +# Recursive truncation +# --------------------------------------------------------------------------- + + +def _truncate_value(value: Any, str_limit: int, list_limit: int) -> Any: + """Recursively truncate *value* using the current per‑type limits.""" + + if isinstance(value, str): + return _truncate_string_middle(value, str_limit) + + if isinstance(value, list): + return _truncate_list_middle(value, str_limit, list_limit) + + if isinstance(value, dict): + return {k: _truncate_value(v, str_limit, list_limit) for k, v in value.items()} + + return value + + +def truncate(value: Any, size_limit: int) -> Any: + """ + Truncate the given value (recursively) so that its string representation + does not exceed size_limit characters. Uses binary search to find the + largest str_limit and list_limit that fit. + """ + + def measure(val): + try: + return len(str(val)) + except Exception: + return sys.getsizeof(val) + + # Reasonable bounds for string and list limits + STR_MIN, STR_MAX = 8, 2**16 + LIST_MIN, LIST_MAX = 1, 2**12 + + # Binary search for the largest str_limit and list_limit that fit + best = None + + # We'll search str_limit first, then list_limit, but can do both together + # For practical purposes, do a grid search with binary search on str_limit for each list_limit + # (since lists are usually the main source of bloat) + # We'll do binary search on list_limit, and for each, binary search on str_limit + + # Outer binary search on list_limit + l_lo, l_hi = LIST_MIN, LIST_MAX + while l_lo <= l_hi: + l_mid = (l_lo + l_hi) // 2 + + # Inner binary search on str_limit + s_lo, s_hi = STR_MIN, STR_MAX + local_best = None + while s_lo <= s_hi: + s_mid = (s_lo + s_hi) // 2 + truncated = _truncate_value(value, s_mid, l_mid) + size = measure(truncated) + if size <= size_limit: + local_best = truncated + s_lo = s_mid + 1 # try to increase str_limit + else: + s_hi = s_mid - 1 # decrease str_limit + + if local_best is not None: + best = local_best + l_lo = l_mid + 1 # try to increase list_limit + else: + l_hi = l_mid - 1 # decrease list_limit + + # If nothing fits, fall back to the most aggressive truncation + if best is None: + best = _truncate_value(value, STR_MIN, LIST_MIN) + + return best diff --git a/autogpt_platform/backend/backend/util/type.py b/autogpt_platform/backend/backend/util/type.py index 2c480b674d8f..e1cda80203a9 100644 --- a/autogpt_platform/backend/backend/util/type.py +++ b/autogpt_platform/backend/backend/util/type.py @@ -1,5 +1,8 @@ import json -from typing import Any, Type, TypeVar, cast, get_args, get_origin +import types +from typing import Any, Type, TypeVar, Union, cast, get_args, get_origin, overload + +from prisma import Json as PrismaJson class ConversionError(ValueError): @@ -102,9 +105,37 @@ def __convert_bool(value: Any) -> bool: return bool(value) -def _try_convert(value: Any, target_type: Type, raise_on_mismatch: bool) -> Any: +def _try_convert(value: Any, target_type: Any, raise_on_mismatch: bool) -> Any: origin = get_origin(target_type) args = get_args(target_type) + + # Handle Union types (including Optional which is Union[T, None]) + if origin is Union or origin is types.UnionType: + # Handle None values for Optional types + if value is None: + if type(None) in args: + return None + elif raise_on_mismatch: + raise TypeError(f"Value {value} is not of expected type {target_type}") + else: + return value + + # Try to convert to each type in the union, excluding None + non_none_types = [arg for arg in args if arg is not type(None)] + + # Try each type in the union, using the original raise_on_mismatch behavior + for arg_type in non_none_types: + try: + return _try_convert(value, arg_type, raise_on_mismatch) + except (TypeError, ValueError, ConversionError): + continue + + # If no conversion succeeded + if raise_on_mismatch: + raise TypeError(f"Value {value} is not of expected type {target_type}") + else: + return value + if origin is None: origin = target_type if origin not in [list, dict, tuple, str, set, int, float, bool]: @@ -180,14 +211,61 @@ def _try_convert(value: Any, target_type: Type, raise_on_mismatch: bool) -> Any: T = TypeVar("T") +TT = TypeVar("TT") def type_match(value: Any, target_type: Type[T]) -> T: return cast(T, _try_convert(value, target_type, raise_on_mismatch=True)) -def convert(value: Any, target_type: Type[T]) -> T: +@overload +def convert(value: Any, target_type: Type[T]) -> T: ... + + +@overload +def convert(value: Any, target_type: Any) -> Any: ... + + +def convert(value: Any, target_type: Any) -> Any: try: - return cast(T, _try_convert(value, target_type, raise_on_mismatch=False)) + if isinstance(value, PrismaJson): + value = value.data + return _try_convert(value, target_type, raise_on_mismatch=False) except Exception as e: raise ConversionError(f"Failed to convert {value} to {target_type}") from e + + +class FormattedStringType(str): + string_format: str + + @classmethod + def __get_pydantic_core_schema__(cls, source_type, handler): + _ = source_type # unused parameter required by pydantic + return handler(str) + + @classmethod + def __get_pydantic_json_schema__(cls, core_schema, handler): + json_schema = handler(core_schema) + json_schema["format"] = cls.string_format + return json_schema + + +class MediaFileType(FormattedStringType): + """ + MediaFile is a string that represents a file. It can be one of the following: + - Data URI: base64 encoded media file. See https://developer.mozilla.org/en-US/docs/Web/URI/Schemes/data/ + - URL: Media file hosted on the internet, it starts with http:// or https://. + - Local path (anything else): A temporary file path living within graph execution time. + + Note: Replace this type alias into a proper class, when more information is needed. + """ + + string_format = "file" + + +class LongTextType(FormattedStringType): + string_format = "long-text" + + +class ShortTextType(FormattedStringType): + string_format = "short-text" diff --git a/autogpt_platform/backend/backend/util/type_test.py b/autogpt_platform/backend/backend/util/type_test.py new file mode 100644 index 000000000000..becadf48b247 --- /dev/null +++ b/autogpt_platform/backend/backend/util/type_test.py @@ -0,0 +1,34 @@ +from typing import List, Optional + +from backend.util.type import convert + + +def test_type_conversion(): + assert convert(5.5, int) == 5 + assert convert("5.5", int) == 5 + assert convert([1, 2, 3], int) == 3 + assert convert("7", Optional[int]) == 7 + assert convert("7", int | None) == 7 + + assert convert("5.5", float) == 5.5 + assert convert(5, float) == 5.0 + + assert convert("True", bool) is True + assert convert("False", bool) is False + + assert convert(5, str) == "5" + assert convert({"a": 1, "b": 2}, str) == '{"a": 1, "b": 2}' + assert convert([1, 2, 3], str) == "[1, 2, 3]" + + assert convert("5", list) == ["5"] + assert convert((1, 2, 3), list) == [1, 2, 3] + assert convert({1, 2, 3}, list) == [1, 2, 3] + + assert convert("5", dict) == {"value": 5} + assert convert('{"a": 1, "b": 2}', dict) == {"a": 1, "b": 2} + assert convert([1, 2, 3], dict) == {0: 1, 1: 2, 2: 3} + assert convert((1, 2, 3), dict) == {0: 1, 1: 2, 2: 3} + + assert convert("5", List[int]) == [5] + assert convert("[5,4,2]", List[int]) == [5, 4, 2] + assert convert([5, 4, 2], List[str]) == ["5", "4", "2"] diff --git a/autogpt_platform/backend/backend/util/virus_scanner.py b/autogpt_platform/backend/backend/util/virus_scanner.py new file mode 100644 index 000000000000..4494c9479065 --- /dev/null +++ b/autogpt_platform/backend/backend/util/virus_scanner.py @@ -0,0 +1,209 @@ +import asyncio +import io +import logging +import time +from typing import Optional, Tuple + +import aioclamd +from pydantic import BaseModel +from pydantic_settings import BaseSettings + +from backend.util.settings import Settings + +logger = logging.getLogger(__name__) +settings = Settings() + + +class VirusScanResult(BaseModel): + is_clean: bool + scan_time_ms: int + file_size: int + threat_name: Optional[str] = None + + +class VirusScannerSettings(BaseSettings): + # Tunables for the scanner layer (NOT the ClamAV daemon). + clamav_service_host: str = "localhost" + clamav_service_port: int = 3310 + clamav_service_timeout: int = 60 + clamav_service_enabled: bool = True + # If the service is disabled, all files are considered clean. + mark_failed_scans_as_clean: bool = False + # Client-side protective limits + max_scan_size: int = 2 * 1024 * 1024 * 1024 # 2 GB guard-rail in memory + min_chunk_size: int = 128 * 1024 # 128 KB hard floor + max_retries: int = 8 # halve ≤ max_retries times + # Concurrency throttle toward the ClamAV daemon. Do *NOT* simply turn this + # up to the number of CPU cores; keep it ≤ (MaxThreads / pods) – 1. + max_concurrency: int = 5 + + +class VirusScannerService: + """Fully-async ClamAV wrapper using **aioclamd**. + + • Reuses a single `ClamdAsyncClient` connection (aioclamd keeps the socket open). + • Throttles concurrent `INSTREAM` calls with an `asyncio.Semaphore` so we don't exhaust daemon worker threads or file descriptors. + • Falls back to progressively smaller chunk sizes when the daemon rejects a stream as too large. + """ + + def __init__(self, settings: VirusScannerSettings) -> None: + self.settings = settings + self._client = aioclamd.ClamdAsyncClient( + host=settings.clamav_service_host, + port=settings.clamav_service_port, + timeout=settings.clamav_service_timeout, + ) + self._sem = asyncio.Semaphore(settings.max_concurrency) + + # ------------------------------------------------------------------ # + # Helpers + # ------------------------------------------------------------------ # + + @staticmethod + def _parse_raw(raw: Optional[dict]) -> Tuple[bool, Optional[str]]: + """ + Convert aioclamd output to (infected?, threat_name). + Returns (False, None) for clean. + """ + if not raw: + return False, None + status, threat = next(iter(raw.values())) + return status == "FOUND", threat + + async def _instream(self, chunk: bytes) -> Tuple[bool, Optional[str]]: + """Scan **one** chunk with concurrency control.""" + async with self._sem: + try: + raw = await self._client.instream(io.BytesIO(chunk)) + return self._parse_raw(raw) + except (BrokenPipeError, ConnectionResetError) as exc: + raise RuntimeError("size-limit") from exc + except Exception as exc: + if "INSTREAM size limit exceeded" in str(exc): + raise RuntimeError("size-limit") from exc + raise + + # ------------------------------------------------------------------ # + # Public API + # ------------------------------------------------------------------ # + + async def scan_file( + self, content: bytes, *, filename: str = "unknown" + ) -> VirusScanResult: + """ + Scan `content`. Returns a result object or raises on infrastructure + failure (unreachable daemon, etc.). + The algorithm always tries whole-file first. If the daemon refuses + on size grounds, it falls back to chunked parallel scanning. + """ + if not self.settings.clamav_service_enabled: + logger.warning(f"Virus scanning disabled – accepting {filename}") + return VirusScanResult( + is_clean=True, scan_time_ms=0, file_size=len(content) + ) + if len(content) > self.settings.max_scan_size: + logger.warning( + f"File {filename} ({len(content)} bytes) exceeds client max scan size ({self.settings.max_scan_size}); Stopping virus scan" + ) + return VirusScanResult( + is_clean=self.settings.mark_failed_scans_as_clean, + file_size=len(content), + scan_time_ms=0, + ) + + # Ensure daemon is reachable (small RTT check) + if not await self._client.ping(): + raise RuntimeError("ClamAV service is unreachable") + + start = time.monotonic() + chunk_size = len(content) # Start with full content length + for retry in range(self.settings.max_retries): + # For small files, don't check min_chunk_size limit + if chunk_size < self.settings.min_chunk_size and chunk_size < len(content): + break + logger.debug( + f"Scanning {filename} with chunk size: {chunk_size // 1_048_576} MB (retry {retry + 1}/{self.settings.max_retries})" + ) + try: + tasks = [ + asyncio.create_task(self._instream(content[o : o + chunk_size])) + for o in range(0, len(content), chunk_size) + ] + for coro in asyncio.as_completed(tasks): + infected, threat = await coro + if infected: + for t in tasks: + if not t.done(): + t.cancel() + return VirusScanResult( + is_clean=False, + threat_name=threat, + file_size=len(content), + scan_time_ms=int((time.monotonic() - start) * 1000), + ) + # All chunks clean + return VirusScanResult( + is_clean=True, + file_size=len(content), + scan_time_ms=int((time.monotonic() - start) * 1000), + ) + except RuntimeError as exc: + if str(exc) == "size-limit": + chunk_size //= 2 + continue + logger.error(f"Cannot scan {filename}: {exc}") + raise + # Phase 3 – give up but warn + logger.warning( + f"Unable to virus scan {filename} ({len(content)} bytes) even with minimum chunk size ({self.settings.min_chunk_size} bytes). Recommend manual review." + ) + return VirusScanResult( + is_clean=self.settings.mark_failed_scans_as_clean, + file_size=len(content), + scan_time_ms=int((time.monotonic() - start) * 1000), + ) + + +_scanner: Optional[VirusScannerService] = None + + +def get_virus_scanner() -> VirusScannerService: + global _scanner + if _scanner is None: + _settings = VirusScannerSettings( + clamav_service_host=settings.config.clamav_service_host, + clamav_service_port=settings.config.clamav_service_port, + clamav_service_enabled=settings.config.clamav_service_enabled, + max_concurrency=settings.config.clamav_max_concurrency, + mark_failed_scans_as_clean=settings.config.clamav_mark_failed_scans_as_clean, + ) + _scanner = VirusScannerService(_settings) + return _scanner + + +async def scan_content_safe(content: bytes, *, filename: str = "unknown") -> None: + """ + Helper function to scan content and raise appropriate exceptions. + + Raises: + VirusDetectedError: If virus is found + VirusScanError: If scanning fails + """ + from backend.server.v2.store.exceptions import VirusDetectedError, VirusScanError + + try: + result = await get_virus_scanner().scan_file(content, filename=filename) + if not result.is_clean: + threat_name = result.threat_name or "Unknown threat" + logger.warning(f"Virus detected in file {filename}: {threat_name}") + raise VirusDetectedError( + threat_name, f"File rejected due to virus detection: {threat_name}" + ) + + logger.info(f"File {filename} passed virus scan in {result.scan_time_ms}ms") + + except VirusDetectedError: + raise + except Exception as e: + logger.error(f"Virus scanning failed for {filename}: {str(e)}") + raise VirusScanError(f"Virus scanning failed: {str(e)}") from e diff --git a/autogpt_platform/backend/backend/util/virus_scanner_test.py b/autogpt_platform/backend/backend/util/virus_scanner_test.py new file mode 100644 index 000000000000..81b5ad334276 --- /dev/null +++ b/autogpt_platform/backend/backend/util/virus_scanner_test.py @@ -0,0 +1,253 @@ +import asyncio +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from backend.server.v2.store.exceptions import VirusDetectedError, VirusScanError +from backend.util.virus_scanner import ( + VirusScannerService, + VirusScannerSettings, + VirusScanResult, + get_virus_scanner, + scan_content_safe, +) + + +class TestVirusScannerService: + @pytest.fixture + def scanner_settings(self): + return VirusScannerSettings( + clamav_service_host="localhost", + clamav_service_port=3310, + clamav_service_enabled=True, + max_scan_size=10 * 1024 * 1024, # 10MB for testing + mark_failed_scans_as_clean=False, # For testing, failed scans should be clean + ) + + @pytest.fixture + def scanner(self, scanner_settings): + return VirusScannerService(scanner_settings) + + @pytest.fixture + def disabled_scanner(self): + settings = VirusScannerSettings(clamav_service_enabled=False) + return VirusScannerService(settings) + + def test_scanner_initialization(self, scanner_settings): + scanner = VirusScannerService(scanner_settings) + assert scanner.settings.clamav_service_host == "localhost" + assert scanner.settings.clamav_service_port == 3310 + assert scanner.settings.clamav_service_enabled is True + + @pytest.mark.asyncio + async def test_scan_disabled_returns_clean(self, disabled_scanner): + content = b"test file content" + result = await disabled_scanner.scan_file(content, filename="test.txt") + + assert result.is_clean is True + assert result.threat_name is None + assert result.file_size == len(content) + assert result.scan_time_ms == 0 + + @pytest.mark.asyncio + async def test_scan_file_too_large(self, scanner): + # Create content larger than max_scan_size + large_content = b"x" * (scanner.settings.max_scan_size + 1) + + # Large files behavior depends on mark_failed_scans_as_clean setting + result = await scanner.scan_file(large_content, filename="large_file.txt") + assert result.is_clean == scanner.settings.mark_failed_scans_as_clean + assert result.file_size == len(large_content) + assert result.scan_time_ms == 0 + + @pytest.mark.asyncio + async def test_scan_file_too_large_both_configurations(self): + """Test large file handling with both mark_failed_scans_as_clean configurations""" + large_content = b"x" * (10 * 1024 * 1024 + 1) # Larger than 10MB + + # Test with mark_failed_scans_as_clean=True + settings_clean = VirusScannerSettings( + max_scan_size=10 * 1024 * 1024, mark_failed_scans_as_clean=True + ) + scanner_clean = VirusScannerService(settings_clean) + result_clean = await scanner_clean.scan_file( + large_content, filename="large_file.txt" + ) + assert result_clean.is_clean is True + + # Test with mark_failed_scans_as_clean=False + settings_dirty = VirusScannerSettings( + max_scan_size=10 * 1024 * 1024, mark_failed_scans_as_clean=False + ) + scanner_dirty = VirusScannerService(settings_dirty) + result_dirty = await scanner_dirty.scan_file( + large_content, filename="large_file.txt" + ) + assert result_dirty.is_clean is False + + # Note: ping method was removed from current implementation + + @pytest.mark.asyncio + async def test_scan_clean_file(self, scanner): + async def mock_instream(_): + await asyncio.sleep(0.001) # Small delay to ensure timing > 0 + return None # No virus detected + + mock_client = Mock() + mock_client.ping = AsyncMock(return_value=True) + mock_client.instream = AsyncMock(side_effect=mock_instream) + + # Replace the client instance that was created in the constructor + scanner._client = mock_client + + content = b"clean file content" + result = await scanner.scan_file(content, filename="clean.txt") + + assert result.is_clean is True + assert result.threat_name is None + assert result.file_size == len(content) + assert result.scan_time_ms > 0 + + @pytest.mark.asyncio + async def test_scan_infected_file(self, scanner): + async def mock_instream(_): + await asyncio.sleep(0.001) # Small delay to ensure timing > 0 + return {"stream": ("FOUND", "Win.Test.EICAR_HDB-1")} + + mock_client = Mock() + mock_client.ping = AsyncMock(return_value=True) + mock_client.instream = AsyncMock(side_effect=mock_instream) + + # Replace the client instance that was created in the constructor + scanner._client = mock_client + + content = b"infected file content" + result = await scanner.scan_file(content, filename="infected.txt") + + assert result.is_clean is False + assert result.threat_name == "Win.Test.EICAR_HDB-1" + assert result.file_size == len(content) + assert result.scan_time_ms > 0 + + @pytest.mark.asyncio + async def test_scan_clamav_unavailable_fail_safe(self, scanner): + mock_client = Mock() + mock_client.ping = AsyncMock(return_value=False) + + # Replace the client instance that was created in the constructor + scanner._client = mock_client + + content = b"test content" + + with pytest.raises(RuntimeError, match="ClamAV service is unreachable"): + await scanner.scan_file(content, filename="test.txt") + + @pytest.mark.asyncio + async def test_scan_error_fail_safe(self, scanner): + mock_client = Mock() + mock_client.ping = AsyncMock(return_value=True) + mock_client.instream = AsyncMock(side_effect=Exception("Scanning error")) + + # Replace the client instance that was created in the constructor + scanner._client = mock_client + + content = b"test content" + + with pytest.raises(Exception, match="Scanning error"): + await scanner.scan_file(content, filename="test.txt") + + # Note: scan_file_method and scan_upload_file tests removed as these APIs don't exist in current implementation + + def test_get_virus_scanner_singleton(self): + scanner1 = get_virus_scanner() + scanner2 = get_virus_scanner() + + # Should return the same instance + assert scanner1 is scanner2 + + # Note: client_reuse test removed as _get_client method doesn't exist in current implementation + + def test_scan_result_model(self): + # Test VirusScanResult model + result = VirusScanResult( + is_clean=False, threat_name="Test.Virus", scan_time_ms=150, file_size=1024 + ) + + assert result.is_clean is False + assert result.threat_name == "Test.Virus" + assert result.scan_time_ms == 150 + assert result.file_size == 1024 + + @pytest.mark.asyncio + async def test_concurrent_scans(self, scanner): + async def mock_instream(_): + await asyncio.sleep(0.001) # Small delay to ensure timing > 0 + return None + + mock_client = Mock() + mock_client.ping = AsyncMock(return_value=True) + mock_client.instream = AsyncMock(side_effect=mock_instream) + + # Replace the client instance that was created in the constructor + scanner._client = mock_client + + content1 = b"file1 content" + content2 = b"file2 content" + + # Run concurrent scans + results = await asyncio.gather( + scanner.scan_file(content1, filename="file1.txt"), + scanner.scan_file(content2, filename="file2.txt"), + ) + + assert len(results) == 2 + assert all(result.is_clean for result in results) + assert results[0].file_size == len(content1) + assert results[1].file_size == len(content2) + assert all(result.scan_time_ms > 0 for result in results) + + +class TestHelperFunctions: + """Test the helper functions scan_content_safe""" + + @pytest.mark.asyncio + async def test_scan_content_safe_clean(self): + """Test scan_content_safe with clean content""" + with patch("backend.util.virus_scanner.get_virus_scanner") as mock_get_scanner: + mock_scanner = Mock() + mock_scanner.scan_file = AsyncMock() + mock_scanner.scan_file.return_value = Mock( + is_clean=True, threat_name=None, scan_time_ms=50, file_size=100 + ) + mock_get_scanner.return_value = mock_scanner + + # Should not raise any exception + await scan_content_safe(b"clean content", filename="test.txt") + + @pytest.mark.asyncio + async def test_scan_content_safe_infected(self): + """Test scan_content_safe with infected content""" + with patch("backend.util.virus_scanner.get_virus_scanner") as mock_get_scanner: + mock_scanner = Mock() + mock_scanner.scan_file = AsyncMock() + mock_scanner.scan_file.return_value = Mock( + is_clean=False, threat_name="Test.Virus", scan_time_ms=50, file_size=100 + ) + mock_get_scanner.return_value = mock_scanner + + with pytest.raises(VirusDetectedError) as exc_info: + await scan_content_safe(b"infected content", filename="virus.txt") + + assert exc_info.value.threat_name == "Test.Virus" + + @pytest.mark.asyncio + async def test_scan_content_safe_scan_error(self): + """Test scan_content_safe when scanning fails""" + with patch("backend.util.virus_scanner.get_virus_scanner") as mock_get_scanner: + mock_scanner = Mock() + mock_scanner.scan_file = AsyncMock() + mock_scanner.scan_file.side_effect = Exception("Scan failed") + mock_get_scanner.return_value = mock_scanner + + with pytest.raises(VirusScanError, match="Virus scanning failed"): + await scan_content_safe(b"test content", filename="test.txt") diff --git a/autogpt_platform/backend/clean_test_db.py b/autogpt_platform/backend/clean_test_db.py new file mode 100644 index 000000000000..da155c6f75fc --- /dev/null +++ b/autogpt_platform/backend/clean_test_db.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Clean the test database by removing all data while preserving the schema. + +Usage: + poetry run python clean_test_db.py [--yes] + +Options: + --yes Skip confirmation prompt +""" + +import asyncio +import sys + +from prisma import Prisma + + +async def main(): + db = Prisma() + await db.connect() + + print("=" * 60) + print("Cleaning Test Database") + print("=" * 60) + print() + + # Get initial counts + user_count = await db.user.count() + agent_count = await db.agentgraph.count() + + print(f"Current data: {user_count} users, {agent_count} agent graphs") + + if user_count == 0 and agent_count == 0: + print("Database is already clean!") + await db.disconnect() + return + + # Check for --yes flag + skip_confirm = "--yes" in sys.argv + + if not skip_confirm: + response = input("\nDo you want to clean all data? (yes/no): ") + if response.lower() != "yes": + print("Aborted.") + await db.disconnect() + return + + print("\nCleaning database...") + + # Delete in reverse order of dependencies + tables = [ + ("UserNotificationBatch", db.usernotificationbatch), + ("NotificationEvent", db.notificationevent), + ("CreditRefundRequest", db.creditrefundrequest), + ("StoreListingReview", db.storelistingreview), + ("StoreListingVersion", db.storelistingversion), + ("StoreListing", db.storelisting), + ("AgentNodeExecutionInputOutput", db.agentnodeexecutioninputoutput), + ("AgentNodeExecution", db.agentnodeexecution), + ("AgentGraphExecution", db.agentgraphexecution), + ("AgentNodeLink", db.agentnodelink), + ("LibraryAgent", db.libraryagent), + ("AgentPreset", db.agentpreset), + ("IntegrationWebhook", db.integrationwebhook), + ("AgentNode", db.agentnode), + ("AgentGraph", db.agentgraph), + ("AgentBlock", db.agentblock), + ("APIKey", db.apikey), + ("CreditTransaction", db.credittransaction), + ("AnalyticsMetrics", db.analyticsmetrics), + ("AnalyticsDetails", db.analyticsdetails), + ("Profile", db.profile), + ("UserOnboarding", db.useronboarding), + ("User", db.user), + ] + + for table_name, table in tables: + try: + count = await table.count() + if count > 0: + await table.delete_many() + print(f"✓ Deleted {count} records from {table_name}") + except Exception as e: + print(f"⚠ Error cleaning {table_name}: {e}") + + # Refresh materialized views (they should be empty now) + try: + await db.execute_raw("SELECT refresh_store_materialized_views();") + print("\n✓ Refreshed materialized views") + except Exception as e: + print(f"\n⚠ Could not refresh materialized views: {e}") + + await db.disconnect() + + print("\n" + "=" * 60) + print("Database cleaned successfully!") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/autogpt_platform/backend/docker-compose.test.yaml b/autogpt_platform/backend/docker-compose.test.yaml index f2a5f1bc2a84..259d52c49748 100644 --- a/autogpt_platform/backend/docker-compose.test.yaml +++ b/autogpt_platform/backend/docker-compose.test.yaml @@ -1,32 +1,90 @@ +networks: + app-network: + name: app-network + shared-network: + name: shared-network + +volumes: + supabase-config: + +x-agpt-services: + &agpt-services + networks: + - app-network + - shared-network + +x-supabase-services: + &supabase-services + networks: + - app-network + - shared-network + + +volumes: + clamav-data: + services: - postgres-test: - image: ankane/pgvector:latest - environment: - - POSTGRES_USER=${DB_USER} - - POSTGRES_PASSWORD=${DB_PASS} - - POSTGRES_DB=${DB_NAME} - healthcheck: - test: pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB - interval: 10s - timeout: 5s - retries: 5 + + db: + <<: *supabase-services + extends: + file: ../db/docker/docker-compose.yml + service: db ports: - - "${DB_PORT}:5432" - networks: - - app-network-test - redis-test: + - ${POSTGRES_PORT}:5432 # We don't use Supavisor locally, so we expose the db directly. + + vector: + <<: *supabase-services + extends: + file: ../db/docker/docker-compose.yml + service: vector + + redis: + <<: *agpt-services image: redis:latest command: redis-server --requirepass password ports: - "6379:6379" - networks: - - app-network-test healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 + rabbitmq: + <<: *agpt-services + image: rabbitmq:management + container_name: rabbitmq + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 30s + timeout: 10s + retries: 5 + start_period: 10s + environment: + - RABBITMQ_DEFAULT_USER=rabbitmq_user_default + - RABBITMQ_DEFAULT_PASS=k0VMxyIJF9S35f3x2uaw5IWAl6Y536O7 + ports: + - "5672:5672" + - "15672:15672" + clamav: + image: clamav/clamav-debian:latest + ports: + - "3310:3310" + volumes: + - clamav-data:/var/lib/clamav + environment: + - CLAMAV_NO_FRESHCLAMD=false + - CLAMD_CONF_StreamMaxLength=50M + - CLAMD_CONF_MaxFileSize=100M + - CLAMD_CONF_MaxScanSize=100M + - CLAMD_CONF_MaxThreads=12 + - CLAMD_CONF_ReadTimeout=300 + healthcheck: + test: ["CMD-SHELL", "clamdscan --version || exit 1"] + interval: 30s + timeout: 10s + retries: 3 networks: app-network-test: driver: bridge diff --git a/autogpt_platform/backend/linter.py b/autogpt_platform/backend/linter.py index a85fd6e9dc23..a86e6761f7a0 100644 --- a/autogpt_platform/backend/linter.py +++ b/autogpt_platform/backend/linter.py @@ -1,5 +1,6 @@ import os import subprocess +import sys directory = os.path.dirname(os.path.realpath(__file__)) @@ -10,19 +11,37 @@ def run(*command: str) -> None: print(f">>>>> Running poetry run {' '.join(command)}") - subprocess.run(["poetry", "run"] + list(command), cwd=directory, check=True) + try: + subprocess.run( + ["poetry", "run"] + list(command), + cwd=directory, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + except subprocess.CalledProcessError as e: + print(e.output.decode("utf-8"), file=sys.stderr) + raise def lint(): - try: - run("ruff", "check", *TARGET_DIRS, "--exit-zero") - run("ruff", "format", "--diff", "--check", LIBS_DIR) - run("isort", "--diff", "--check", "--profile", "black", BACKEND_DIR) - run("black", "--diff", "--check", BACKEND_DIR) - run("pyright", *TARGET_DIRS) - except subprocess.CalledProcessError as e: - print("Lint failed, try running `poetry run format` to fix the issues: ", e) - raise e + lint_step_args: list[list[str]] = [ + ["ruff", "check", *TARGET_DIRS, "--exit-zero"], + ["ruff", "format", "--diff", "--check", LIBS_DIR], + ["isort", "--diff", "--check", "--profile", "black", BACKEND_DIR], + ["black", "--diff", "--check", BACKEND_DIR], + ["pyright", *TARGET_DIRS], + ] + lint_error = None + for args in lint_step_args: + try: + run(*args) + except subprocess.CalledProcessError as e: + lint_error = e + + if lint_error: + print("Lint failed, try running `poetry run format` to fix the issues") + sys.exit(1) def format(): diff --git a/autogpt_platform/backend/migrations/20241230102007_update_store_agent_view/migration.sql b/autogpt_platform/backend/migrations/20241230102007_update_store_agent_view/migration.sql new file mode 100644 index 000000000000..76cabcb57426 --- /dev/null +++ b/autogpt_platform/backend/migrations/20241230102007_update_store_agent_view/migration.sql @@ -0,0 +1,50 @@ +BEGIN; + +DROP VIEW IF EXISTS "StoreAgent"; + +CREATE VIEW "StoreAgent" AS +WITH ReviewStats AS ( + SELECT sl."id" AS "storeListingId", + COUNT(sr.id) AS review_count, + AVG(CAST(sr.score AS DECIMAL)) AS avg_rating + FROM "StoreListing" sl + JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl."id" + JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id + WHERE sl."isDeleted" = FALSE + GROUP BY sl."id" +), +AgentRuns AS ( + SELECT "agentGraphId", COUNT(*) AS run_count + FROM "AgentGraphExecution" + GROUP BY "agentGraphId" +) +SELECT + sl.id AS listing_id, + slv.id AS "storeListingVersionId", + slv."createdAt" AS updated_at, + slv.slug, + slv.name AS agent_name, + slv."videoUrl" AS agent_video, + COALESCE(slv."imageUrls", ARRAY[]::TEXT[]) AS agent_image, + slv."isFeatured" AS featured, + p.username AS creator_username, + p."avatarUrl" AS creator_avatar, + slv."subHeading" AS sub_heading, + slv.description, + slv.categories, + COALESCE(ar.run_count, 0) AS runs, + CAST(COALESCE(rs.avg_rating, 0.0) AS DOUBLE PRECISION) AS rating, + ARRAY_AGG(DISTINCT CAST(slv.version AS TEXT)) AS versions +FROM "StoreListing" sl +JOIN "AgentGraph" a ON sl."agentId" = a.id AND sl."agentVersion" = a."version" +LEFT JOIN "Profile" p ON sl."owningUserId" = p."userId" +LEFT JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id +LEFT JOIN ReviewStats rs ON sl.id = rs."storeListingId" +LEFT JOIN AgentRuns ar ON a.id = ar."agentGraphId" +WHERE sl."isDeleted" = FALSE + AND sl."isApproved" = TRUE +GROUP BY sl.id, slv.id, slv.slug, slv."createdAt", slv.name, slv."videoUrl", slv."imageUrls", slv."isFeatured", + p.username, p."avatarUrl", slv."subHeading", slv.description, slv.categories, + ar.run_count, rs.avg_rating; + +COMMIT; diff --git a/autogpt_platform/backend/migrations/20250103143207_add_terminated_execution_status/migration.sql b/autogpt_platform/backend/migrations/20250103143207_add_terminated_execution_status/migration.sql new file mode 100644 index 000000000000..0b3a2a27b9be --- /dev/null +++ b/autogpt_platform/backend/migrations/20250103143207_add_terminated_execution_status/migration.sql @@ -0,0 +1,2 @@ +-- Add "TERMINATED" to execution status enum type +ALTER TYPE "AgentExecutionStatus" ADD VALUE 'TERMINATED'; diff --git a/autogpt_platform/backend/migrations/20250105254106_migrate_brace_to_double_brace_string_format/migration.sql b/autogpt_platform/backend/migrations/20250105254106_migrate_brace_to_double_brace_string_format/migration.sql new file mode 100644 index 000000000000..2a7141b0f9c8 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250105254106_migrate_brace_to_double_brace_string_format/migration.sql @@ -0,0 +1,86 @@ +/* + Warnings: + - You are about replace a single brace string input format for the following blocks: + - AgentOutputBlock + - FillTextTemplateBlock + - AITextGeneratorBlock + - AIStructuredResponseGeneratorBlock + with a double brace format. + - This migration can be slow for a large updated AgentNode tables. +*/ +BEGIN; +SET LOCAL statement_timeout = '10min'; + +WITH to_update AS ( + SELECT + "id", + "agentBlockId", + "constantInput"::jsonb AS j + FROM "AgentNode" + WHERE + "agentBlockId" IN ( + '363ae599-353e-4804-937e-b2ee3cef3da4', -- AgentOutputBlock + 'db7d8f02-2f44-4c55-ab7a-eae0941f0c30', -- FillTextTemplateBlock + '1f292d4a-41a4-4977-9684-7c8d560b9f91', -- AITextGeneratorBlock + 'ed55ac19-356e-4243-a6cb-bc599e9b716f' -- AIStructuredResponseGeneratorBlock + ) + AND ( + "constantInput"::jsonb->>'format' ~ '(?>'prompt' ~ '(?>'sys_prompt' ~ '(?>'format' ~ '(?>'format', + '(?>'prompt' ~ '(?>'prompt', + '(?>'sys_prompt' ~ '(?>'sys_prompt', + '(?block_id. + +*/ +BEGIN; + +-- DropForeignKey blockId +ALTER TABLE "CreditTransaction" DROP CONSTRAINT "CreditTransaction_blockId_fkey"; + +-- Update migrate blockId into metadata->"block_id" +UPDATE "CreditTransaction" +SET "metadata" = jsonb_set( + COALESCE("metadata"::jsonb, '{}'), + '{block_id}', + to_jsonb("blockId") +) +WHERE "blockId" IS NOT NULL; + +-- AlterTable drop blockId +ALTER TABLE "CreditTransaction" DROP COLUMN "blockId"; + +COMMIT; + +/* + These indices dropped below were part of the cleanup during the schema change applied above. + These indexes were not useful and will not impact anything upon their removal. +*/ + +-- DropIndex +DROP INDEX "StoreListingReview_storeListingVersionId_idx"; + +-- DropIndex +DROP INDEX "StoreListingSubmission_Status_idx"; diff --git a/autogpt_platform/backend/migrations/20250115432618_add_auto_top_up_config/migration.sql b/autogpt_platform/backend/migrations/20250115432618_add_auto_top_up_config/migration.sql new file mode 100644 index 000000000000..c336b3509c96 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250115432618_add_auto_top_up_config/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "topUpConfig" JSONB; diff --git a/autogpt_platform/backend/migrations/20250124211747_make_store_listing_version_id_unique/migration.sql b/autogpt_platform/backend/migrations/20250124211747_make_store_listing_version_id_unique/migration.sql new file mode 100644 index 000000000000..a1841b917c90 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250124211747_make_store_listing_version_id_unique/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[storeListingVersionId]` on the table `StoreListingSubmission` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "StoreListingSubmission_storeListingVersionId_key" ON "StoreListingSubmission"("storeListingVersionId"); diff --git a/autogpt_platform/backend/migrations/20250203133647_add_image_url/migration.sql b/autogpt_platform/backend/migrations/20250203133647_add_image_url/migration.sql new file mode 100644 index 000000000000..33a9bbf1d995 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250203133647_add_image_url/migration.sql @@ -0,0 +1,8 @@ +-- Add imageUrl column +ALTER TABLE "LibraryAgent" +ADD COLUMN "creatorId" TEXT, +ADD COLUMN "imageUrl" TEXT; + +-- Add foreign key constraint for creatorId -> Profile +ALTER TABLE "LibraryAgent" +ADD CONSTRAINT "LibraryAgent_creatorId_fkey" FOREIGN KEY ("creatorId") REFERENCES "Profile"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/migrations/20250205100104_add_profile_trigger/migration.sql b/autogpt_platform/backend/migrations/20250205100104_add_profile_trigger/migration.sql new file mode 100644 index 000000000000..2a41961b3292 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250205100104_add_profile_trigger/migration.sql @@ -0,0 +1,109 @@ +CREATE OR REPLACE FUNCTION generate_username() +RETURNS TEXT AS $$ +DECLARE + -- Random username generation + selected_adjective TEXT; + selected_animal TEXT; + random_int INT; + generated_username TEXT; +BEGIN + FOR i IN 1..10 LOOP + SELECT unnest + INTO selected_adjective + FROM (VALUES ('happy'), ('clever'), ('swift'), ('bright'), ('wise'), ('funny'), ('cool'), ('awesome'), ('amazing'), ('fantastic'), ('wonderful')) AS t(unnest) + ORDER BY random() + LIMIT 1; + + SELECT unnest + INTO selected_animal + FROM (VALUES ('fox'), ('wolf'), ('bear'), ('eagle'), ('owl'), ('tiger'), ('lion'), ('elephant'), ('giraffe'), ('zebra')) AS t(unnest) + ORDER BY random() + LIMIT 1; + + SELECT floor(random() * (99999 - 10000 + 1) + 10000)::int + INTO random_int; + + generated_username := lower(selected_adjective || '-' || selected_animal || '-' || random_int); + + -- Check if username is already taken + IF NOT EXISTS ( + SELECT 1 FROM platform."Profile" WHERE username = generated_username + ) THEN + -- Username is unique, exit the loop + EXIT; + END IF; + + -- If we've tried 10 times and still haven't found a unique username + IF i = 10 THEN + RAISE EXCEPTION 'Unable to generate unique username after 10 attempts'; + END IF; + END LOOP; + RETURN generated_username; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +CREATE OR REPLACE FUNCTION add_user_and_profile_to_platform() +RETURNS TRIGGER AS $$ +BEGIN + -- Exit early if NEW.id is null to prevent constraint violations + IF NEW.id IS NULL THEN + RAISE EXCEPTION 'Cannot create user/profile: id is null'; + END IF; + + /* + 1) Insert into platform."User" + (If you already have such a row or want different columns, adjust below.) + */ + INSERT INTO platform."User" (id, email, "updatedAt") + VALUES (NEW.id, NEW.email, now()); + + /* + 2) Insert into platform."Profile" + Adjust columns/types depending on how your "Profile" schema is defined: + - "links" might be text[], jsonb, or something else in your table. + - "avatarUrl" and "description" can be defaulted as well. + */ + INSERT INTO platform."Profile" + ("id", "userId", name, username, description, links, "avatarUrl", "updatedAt") + VALUES + ( + NEW.id, + NEW.id, + COALESCE(split_part(NEW.email, '@', 1), 'user'), -- handle null email + platform.generate_username(), + 'I''m new here', + '{}', -- empty array or empty JSON, depending on your column definition + '', + now() + ); + + RETURN NEW; +EXCEPTION + WHEN OTHERS THEN + -- Log the error details + RAISE NOTICE 'Error in add_user_and_profile_to_platform: %', SQLERRM; + -- Re-raise the error + RAISE; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + + +DO $$ +BEGIN + -- Check if the auth schema and users table exist + IF EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'auth' + AND table_name = 'users' + ) THEN + -- Drop the trigger if it exists + DROP TRIGGER IF EXISTS user_added_to_platform ON auth.users; + + -- Create the trigger + CREATE TRIGGER user_added_to_platform + AFTER INSERT ON auth.users + FOR EACH ROW EXECUTE FUNCTION add_user_and_profile_to_platform(); + END IF; +END $$; + diff --git a/autogpt_platform/backend/migrations/20250205110132_add_missing_profiles/migration.sql b/autogpt_platform/backend/migrations/20250205110132_add_missing_profiles/migration.sql new file mode 100644 index 000000000000..f10c5889482a --- /dev/null +++ b/autogpt_platform/backend/migrations/20250205110132_add_missing_profiles/migration.sql @@ -0,0 +1,29 @@ +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'platform' + AND table_name = 'User' + ) AND EXISTS ( + SELECT 1 + FROM information_schema.tables + WHERE table_schema = 'platform' + AND table_name = 'Profile' + ) THEN + INSERT INTO platform."Profile" + ("id", "userId", name, username, description, links, "avatarUrl", "updatedAt") + SELECT + u.id, + u.id, + COALESCE(split_part(u.email, '@', 1), 'user'), + platform.generate_username(), + 'I''m new here', + '{}', + '', + now() + FROM platform."User" u + LEFT JOIN platform."Profile" p ON u.id = p."userId" + WHERE p.id IS NULL; + END IF; +END $$; \ No newline at end of file diff --git a/autogpt_platform/backend/migrations/20250212215755_add_user_notifications/migration.sql b/autogpt_platform/backend/migrations/20250212215755_add_user_notifications/migration.sql new file mode 100644 index 000000000000..c7f5963e2fad --- /dev/null +++ b/autogpt_platform/backend/migrations/20250212215755_add_user_notifications/migration.sql @@ -0,0 +1,45 @@ +-- CreateEnum +CREATE TYPE "NotificationType" AS ENUM ('AGENT_RUN', 'ZERO_BALANCE', 'LOW_BALANCE', 'BLOCK_EXECUTION_FAILED', 'CONTINUOUS_AGENT_ERROR', 'DAILY_SUMMARY', 'WEEKLY_SUMMARY', 'MONTHLY_SUMMARY'); + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "maxEmailsPerDay" INTEGER NOT NULL DEFAULT 3, +ADD COLUMN "notifyOnAgentRun" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "notifyOnBlockExecutionFailed" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "notifyOnContinuousAgentError" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "notifyOnDailySummary" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "notifyOnLowBalance" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "notifyOnMonthlySummary" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "notifyOnWeeklySummary" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "notifyOnZeroBalance" BOOLEAN NOT NULL DEFAULT true; + +-- CreateTable +CREATE TABLE "NotificationEvent" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userNotificationBatchId" TEXT, + "type" "NotificationType" NOT NULL, + "data" JSONB NOT NULL, + + CONSTRAINT "NotificationEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UserNotificationBatch" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "userId" TEXT NOT NULL, + "type" "NotificationType" NOT NULL, + + CONSTRAINT "UserNotificationBatch_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "UserNotificationBatch_userId_type_key" ON "UserNotificationBatch"("userId", "type"); + +-- AddForeignKey +ALTER TABLE "NotificationEvent" ADD CONSTRAINT "NotificationEvent_userNotificationBatchId_fkey" FOREIGN KEY ("userNotificationBatchId") REFERENCES "UserNotificationBatch"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UserNotificationBatch" ADD CONSTRAINT "UserNotificationBatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/migrations/20250213110232_migrate_string_json/migration.sql b/autogpt_platform/backend/migrations/20250213110232_migrate_string_json/migration.sql new file mode 100644 index 000000000000..2d5216aee9ae --- /dev/null +++ b/autogpt_platform/backend/migrations/20250213110232_migrate_string_json/migration.sql @@ -0,0 +1,77 @@ +CREATE OR REPLACE FUNCTION migrate_text_column_to_json( + p_table text, -- Table name, e.g. 'AgentNodeExecution' + p_col text, -- Column name to convert, e.g. 'executionData' + p_default json DEFAULT '{}'::json, -- Fallback value when original value is NULL. + -- Pass NULL here if you prefer to leave NULLs. + p_set_nullable boolean DEFAULT true -- If false, the new column will be NOT NULL. +) RETURNS void AS $$ +DECLARE + full_table text; + tmp_col text; +BEGIN + -- Build a fully qualified table name using the current schema. + full_table := format('%I.%I', current_schema(), p_table); + tmp_col := p_col || '_tmp'; + + -- 0. Skip the migration if the column is already of type jsonb. + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = current_schema() + AND table_name = p_table + AND column_name = p_col + AND data_type = 'jsonb' + ) THEN + RAISE NOTICE 'Column %I.%I is already of type jsonb, skipping migration.', full_table, p_col; + RETURN; + END IF; + + -- 1. Cleanup the original column from invalid JSON characters. + EXECUTE format('UPDATE %s SET %I = replace(%I, E''\\u0000'', '''') WHERE %I LIKE ''%%\\u0000%%'';', full_table, p_col, p_col, p_col); + + -- 2. Add the temporary column of type JSON. + EXECUTE format('ALTER TABLE %s ADD COLUMN %I jsonb;', full_table, tmp_col); + + -- 3. Convert the data: + -- - If p_default IS NOT NULL, use it as the fallback value. + -- - Otherwise, keep NULL. + IF p_default IS NULL THEN + EXECUTE format( + 'UPDATE %s SET %I = CASE WHEN %I IS NULL THEN NULL ELSE %I::json END;', + full_table, tmp_col, p_col, p_col + ); + ELSE + EXECUTE format( + 'UPDATE %s SET %I = CASE WHEN %I IS NULL THEN %L::json ELSE %I::json END;', + full_table, tmp_col, p_col, p_default::text, p_col + ); + END IF; + + -- 4. Drop the original text column. + EXECUTE format('ALTER TABLE %s DROP COLUMN %I;', full_table, p_col); + + -- 5. Rename the temporary column to the original column name. + EXECUTE format('ALTER TABLE %s RENAME COLUMN %I TO %I;', full_table, tmp_col, p_col); + + -- 6. Optionally set a DEFAULT for future inserts if a fallback is provided. + IF p_default IS NOT NULL THEN + EXECUTE format('ALTER TABLE %s ALTER COLUMN %I SET DEFAULT %L::json;', + full_table, p_col, p_default::text); + END IF; + + -- 7. Optionally mark the column as NOT NULL. + IF NOT p_set_nullable THEN + EXECUTE format('ALTER TABLE %s ALTER COLUMN %I SET NOT NULL;', full_table, p_col); + END IF; +END; +$$ LANGUAGE plpgsql; + + +BEGIN; + SELECT migrate_text_column_to_json('AgentGraphExecution', 'stats', NULL, true); + SELECT migrate_text_column_to_json('AgentNodeExecution', 'stats', NULL, true); + SELECT migrate_text_column_to_json('AgentNodeExecution', 'executionData', NULL, true); + SELECT migrate_text_column_to_json('AgentNode', 'constantInput', '{}'::json, false); + SELECT migrate_text_column_to_json('AgentNode', 'metadata', '{}'::json, false); + SELECT migrate_text_column_to_json('AgentNodeExecutionInputOutput', 'data', NULL, false); +COMMIT; diff --git a/autogpt_platform/backend/migrations/20250214092857_add_refund_request/migration.sql b/autogpt_platform/backend/migrations/20250214092857_add_refund_request/migration.sql new file mode 100644 index 000000000000..050eaa550ccf --- /dev/null +++ b/autogpt_platform/backend/migrations/20250214092857_add_refund_request/migration.sql @@ -0,0 +1,20 @@ +-- CreateEnum +CREATE TYPE "CreditRefundRequestStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED'); + +-- CreateTable +CREATE TABLE "CreditRefundRequest" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + "transactionKey" TEXT NOT NULL, + "amount" INTEGER NOT NULL, + "reason" TEXT NOT NULL, + "result" TEXT, + "status" "CreditRefundRequestStatus" NOT NULL DEFAULT 'PENDING', + + CONSTRAINT "CreditRefundRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "CreditRefundRequest_userId_transactionKey_idx" ON "CreditRefundRequest"("userId", "transactionKey"); diff --git a/autogpt_platform/backend/migrations/20250214101759_add_transaction_types/migration.sql b/autogpt_platform/backend/migrations/20250214101759_add_transaction_types/migration.sql new file mode 100644 index 000000000000..946d4e5b8a49 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250214101759_add_transaction_types/migration.sql @@ -0,0 +1,11 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "CreditTransactionType" ADD VALUE 'GRANT'; +ALTER TYPE "CreditTransactionType" ADD VALUE 'REFUND'; +ALTER TYPE "CreditTransactionType" ADD VALUE 'CARD_CHECK'; diff --git a/autogpt_platform/backend/migrations/20250218135013_create_library_agents_for_existing_graphs/migration.sql b/autogpt_platform/backend/migrations/20250218135013_create_library_agents_for_existing_graphs/migration.sql new file mode 100644 index 000000000000..d2f6785a852d --- /dev/null +++ b/autogpt_platform/backend/migrations/20250218135013_create_library_agents_for_existing_graphs/migration.sql @@ -0,0 +1,33 @@ +-- Create LibraryAgents for all AgentGraphs in their owners' library, skipping existing entries +INSERT INTO "LibraryAgent" ( + "id", + "createdAt", + "updatedAt", + "userId", + "agentId", + "agentVersion", + "useGraphIsActiveVersion", + "isFavorite", + "isCreatedByUser", + "isArchived", + "isDeleted") +SELECT + gen_random_uuid(), --> id + ag."createdAt", --> createdAt + ag."createdAt", --> updatedAt + ag."userId", --> userId + ag."id", --> agentId + ag."version", --> agentVersion + true, --> useGraphIsActiveVersion + false, --> isFavorite + true, --> isCreatedByUser + false, --> isArchived + false --> isDeleted +FROM "AgentGraph" AS ag +WHERE ag."isActive" = true +AND NOT EXISTS ( + SELECT 1 + FROM "LibraryAgent" AS la + WHERE la."userId" = ag."userId" + AND la."agentId" = ag."id" +); diff --git a/autogpt_platform/backend/migrations/20250220111649_add_refund_notifications/migration.sql b/autogpt_platform/backend/migrations/20250220111649_add_refund_notifications/migration.sql new file mode 100644 index 000000000000..10609ad8021e --- /dev/null +++ b/autogpt_platform/backend/migrations/20250220111649_add_refund_notifications/migration.sql @@ -0,0 +1,9 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + +ALTER TYPE "NotificationType" ADD VALUE 'REFUND_REQUEST'; +ALTER TYPE "NotificationType" ADD VALUE 'REFUND_PROCESSED'; diff --git a/autogpt_platform/backend/migrations/20250222014114_add_email_verified_flag_to_user_table/migration.sql b/autogpt_platform/backend/migrations/20250222014114_add_email_verified_flag_to_user_table/migration.sql new file mode 100644 index 000000000000..ed340c6de3d0 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250222014114_add_email_verified_flag_to_user_table/migration.sql @@ -0,0 +1,10 @@ +-- First, add the column as nullable to avoid issues with existing rows +ALTER TABLE "User" ADD COLUMN "emailVerified" BOOLEAN; + +-- Set default values for existing rows +UPDATE "User" SET "emailVerified" = true; + +-- Now make it NOT NULL and set the default +ALTER TABLE "User" ALTER COLUMN "emailVerified" SET NOT NULL; +ALTER TABLE "User" ALTER COLUMN "emailVerified" SET DEFAULT true; + diff --git a/autogpt_platform/backend/migrations/20250223110000_add_onboarding_model/migration.sql b/autogpt_platform/backend/migrations/20250223110000_add_onboarding_model/migration.sql new file mode 100644 index 000000000000..3b149216397c --- /dev/null +++ b/autogpt_platform/backend/migrations/20250223110000_add_onboarding_model/migration.sql @@ -0,0 +1,26 @@ +-- Create UserOnboarding table +CREATE TABLE "UserOnboarding" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "step" INTEGER NOT NULL DEFAULT 0, + "usageReason" TEXT, + "integrations" TEXT[] DEFAULT ARRAY[]::TEXT[], + "otherIntegrations" TEXT, + "selectedAgentCreator" TEXT, + "selectedAgentSlug" TEXT, + "agentInput" JSONB, + "isCompleted" BOOLEAN NOT NULL DEFAULT false, + "userId" TEXT NOT NULL, + + CONSTRAINT "UserOnboarding_pkey" PRIMARY KEY ("id") +); + +-- Create unique constraint on userId +ALTER TABLE "UserOnboarding" ADD CONSTRAINT "UserOnboarding_userId_key" UNIQUE ("userId"); + +-- Create index on userId +CREATE INDEX "UserOnboarding_userId_idx" ON "UserOnboarding"("userId"); + +-- Add foreign key constraint +ALTER TABLE "UserOnboarding" ADD CONSTRAINT "UserOnboarding_userId_fkey" +FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/migrations/20250227140210_fix_library_presets_relations/migration.sql b/autogpt_platform/backend/migrations/20250227140210_fix_library_presets_relations/migration.sql new file mode 100644 index 000000000000..8553d774297c --- /dev/null +++ b/autogpt_platform/backend/migrations/20250227140210_fix_library_presets_relations/migration.sql @@ -0,0 +1,50 @@ +/* + Warnings: + - The relation LibraryAgent:AgentPreset was REMOVED + - A unique constraint covering the columns `[userId,agentGraphId,agentGraphVersion]` on the table `LibraryAgent` will be added. If there are existing duplicate values, this will fail. + - The foreign key constraints on AgentPreset and LibraryAgent are being changed from CASCADE to RESTRICT for AgentGraph deletion, which means you cannot delete AgentGraphs that have associated LibraryAgents or AgentPresets. + + Use the following query to check whether these conditions are satisfied: + + -- Check for duplicate LibraryAgent userId + agentGraphId + agentGraphVersion combinations that would violate the new unique constraint + SELECT la."userId", + la."agentId" as graph_id, + la."agentVersion" as graph_version, + COUNT(*) as multiplicity + FROM "LibraryAgent" la + GROUP BY la."userId", + la."agentId", + la."agentVersion" + HAVING COUNT(*) > 1; +*/ + +-- Drop foreign key constraints on columns we're about to rename +ALTER TABLE "AgentPreset" DROP CONSTRAINT "AgentPreset_agentId_agentVersion_fkey"; +ALTER TABLE "LibraryAgent" DROP CONSTRAINT "LibraryAgent_agentId_agentVersion_fkey"; +ALTER TABLE "LibraryAgent" DROP CONSTRAINT "LibraryAgent_agentPresetId_fkey"; + +-- Rename columns in AgentPreset +ALTER TABLE "AgentPreset" RENAME COLUMN "agentId" TO "agentGraphId"; +ALTER TABLE "AgentPreset" RENAME COLUMN "agentVersion" TO "agentGraphVersion"; + +-- Rename columns in LibraryAgent +ALTER TABLE "LibraryAgent" RENAME COLUMN "agentId" TO "agentGraphId"; +ALTER TABLE "LibraryAgent" RENAME COLUMN "agentVersion" TO "agentGraphVersion"; + +-- Drop LibraryAgent.agentPresetId column +ALTER TABLE "LibraryAgent" DROP COLUMN "agentPresetId"; + +-- Replace userId index with unique index on userId + agentGraphId + agentGraphVersion +DROP INDEX "LibraryAgent_userId_idx"; +CREATE UNIQUE INDEX "LibraryAgent_userId_agentGraphId_agentGraphVersion_key" ON "LibraryAgent"("userId", "agentGraphId", "agentGraphVersion"); + +-- Re-add the foreign key constraints with new column names +ALTER TABLE "LibraryAgent" ADD CONSTRAINT "LibraryAgent_agentGraphId_agentGraphVersion_fkey" +FOREIGN KEY ("agentGraphId", "agentGraphVersion") REFERENCES "AgentGraph"("id", "version") +ON DELETE RESTRICT -- Disallow deleting AgentGraph when still referenced by existing LibraryAgents +ON UPDATE CASCADE; + +ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_agentGraphId_agentGraphVersion_fkey" +FOREIGN KEY ("agentGraphId", "agentGraphVersion") REFERENCES "AgentGraph"("id", "version") +ON DELETE RESTRICT -- Disallow deleting AgentGraph when still referenced by existing AgentPresets +ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/migrations/20250228161607_agent_graph_execution_soft_delete/migration.sql b/autogpt_platform/backend/migrations/20250228161607_agent_graph_execution_soft_delete/migration.sql new file mode 100644 index 000000000000..ef091b9c80c6 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250228161607_agent_graph_execution_soft_delete/migration.sql @@ -0,0 +1,6 @@ +-- Add isDeleted column to AgentGraphExecution +ALTER TABLE "AgentGraphExecution" +ADD COLUMN "isDeleted" + BOOLEAN + NOT NULL + DEFAULT false; diff --git a/autogpt_platform/backend/migrations/20250310095931_delete_duplicate_indices/migration.sql b/autogpt_platform/backend/migrations/20250310095931_delete_duplicate_indices/migration.sql new file mode 100644 index 000000000000..981ee6c85922 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250310095931_delete_duplicate_indices/migration.sql @@ -0,0 +1,11 @@ +-- DropIndex +DROP INDEX "APIKey_userId_idx"; + +-- DropIndex +DROP INDEX "StoreListing_agentId_owningUserId_idx"; + +-- DropIndex +DROP INDEX "StoreListing_isDeleted_idx"; + +-- DropIndex +DROP INDEX "StoreListingVersion_agentId_agentVersion_isDeleted_idx"; diff --git a/autogpt_platform/backend/migrations/20250316095525_remove_graph_template/migration.sql b/autogpt_platform/backend/migrations/20250316095525_remove_graph_template/migration.sql new file mode 100644 index 000000000000..2a9e29cfb814 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250316095525_remove_graph_template/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - You are about to drop the column `isTemplate` on the `AgentGraph` table. All the data in the column will be lost. + +*/ +-- AlterTable +ALTER TABLE "AgentGraph" DROP COLUMN "isTemplate"; diff --git a/autogpt_platform/backend/migrations/20250318043016_update_store_submissions_format/migration.sql b/autogpt_platform/backend/migrations/20250318043016_update_store_submissions_format/migration.sql new file mode 100644 index 000000000000..c8c7edac7087 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250318043016_update_store_submissions_format/migration.sql @@ -0,0 +1,372 @@ +/* + Warnings: + + - The enum type "SubmissionStatus" will be replaced. The 'DAFT' value is removed, so any data using 'DAFT' will be updated to 'DRAFT'. If there are rows still expecting 'DAFT' after this change, it will fail. + - You are about to drop the column "isApproved" on the "StoreListing" table. All the data in that column will be lost. + - You are about to drop the column "slug" on the "StoreListingVersion" table. All the data in that column will be lost. + - You are about to drop the "StoreListingSubmission" table. Data in that table (beyond what is copied over) will be permanently lost. + - A unique constraint covering the column "activeVersionId" on the "StoreListing" table will be added. If duplicates already exist, this will fail. + - A unique constraint covering the columns ("storeListingId","version") on "StoreListingVersion" will be added. If duplicates already exist, this will fail. + - The "storeListingId" column on "StoreListingVersion" is set to NOT NULL. If any rows currently have a NULL value, this step will fail. + - The views "StoreSubmission", "StoreAgent", and "Creator" are dropped and recreated. Any usage or references to them will be momentarily disrupted until the views are recreated. +*/ + +BEGIN; + +-- First, drop all views that depend on the columns and types we're modifying +DROP VIEW IF EXISTS "StoreSubmission"; +DROP VIEW IF EXISTS "StoreAgent"; +DROP VIEW IF EXISTS "Creator"; + +-- Create the new enum type +CREATE TYPE "SubmissionStatus_new" AS ENUM ('DRAFT', 'PENDING', 'APPROVED', 'REJECTED'); + +-- Modify the column with the correct casing (Status with capital S) +ALTER TABLE "StoreListingSubmission" ALTER COLUMN "Status" DROP DEFAULT; +ALTER TABLE "StoreListingSubmission" + ALTER COLUMN "Status" TYPE "SubmissionStatus_new" + USING ( + CASE WHEN "Status"::text = 'DAFT' THEN 'DRAFT'::text + ELSE "Status"::text + END + )::"SubmissionStatus_new"; + +-- Rename the enum types +ALTER TYPE "SubmissionStatus" RENAME TO "SubmissionStatus_old"; +ALTER TYPE "SubmissionStatus_new" RENAME TO "SubmissionStatus"; +DROP TYPE "SubmissionStatus_old"; + +-- Set default back +ALTER TABLE "StoreListingSubmission" ALTER COLUMN "Status" SET DEFAULT 'PENDING'; + +-- Drop constraints +ALTER TABLE "StoreListingSubmission" DROP CONSTRAINT IF EXISTS "StoreListingSubmission_reviewerId_fkey"; + +-- Drop indexes +DROP INDEX IF EXISTS "StoreListing_isDeleted_isApproved_idx"; +DROP INDEX IF EXISTS "StoreListingSubmission_storeListingVersionId_key"; + +-- Modify StoreListing +ALTER TABLE "StoreListing" + DROP COLUMN IF EXISTS "isApproved", + ADD COLUMN IF NOT EXISTS "activeVersionId" TEXT, + ADD COLUMN IF NOT EXISTS "hasApprovedVersion" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "slug" TEXT; + +-- First add ALL columns to StoreListingVersion (including the submissionStatus column) +ALTER TABLE "StoreListingVersion" + ADD COLUMN IF NOT EXISTS "reviewerId" TEXT, + ADD COLUMN IF NOT EXISTS "reviewComments" TEXT, + ADD COLUMN IF NOT EXISTS "internalComments" TEXT, + ADD COLUMN IF NOT EXISTS "reviewedAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "changesSummary" TEXT, + ADD COLUMN IF NOT EXISTS "submissionStatus" "SubmissionStatus" NOT NULL DEFAULT 'DRAFT', + ADD COLUMN IF NOT EXISTS "submittedAt" TIMESTAMP(3), + ALTER COLUMN "storeListingId" SET NOT NULL; + +-- NOW copy data from StoreListingSubmission to StoreListingVersion +DO $$ +BEGIN + -- First, check what columns actually exist in the StoreListingSubmission table + DECLARE + has_reviewerId BOOLEAN := ( + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'StoreListingSubmission' + AND column_name = 'reviewerId' + ) + ); + + has_reviewComments BOOLEAN := ( + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'StoreListingSubmission' + AND column_name = 'reviewComments' + ) + ); + + has_changesSummary BOOLEAN := ( + SELECT EXISTS ( + SELECT FROM information_schema.columns + WHERE table_name = 'StoreListingSubmission' + AND column_name = 'changesSummary' + ) + ); + BEGIN + -- Only copy fields that we know exist + IF has_reviewerId THEN + UPDATE "StoreListingVersion" AS v + SET "reviewerId" = s."reviewerId" + FROM "StoreListingSubmission" AS s + WHERE v."id" = s."storeListingVersionId"; + END IF; + + IF has_reviewComments THEN + UPDATE "StoreListingVersion" AS v + SET "reviewComments" = s."reviewComments" + FROM "StoreListingSubmission" AS s + WHERE v."id" = s."storeListingVersionId"; + END IF; + + IF has_changesSummary THEN + UPDATE "StoreListingVersion" AS v + SET "changesSummary" = s."changesSummary" + FROM "StoreListingSubmission" AS s + WHERE v."id" = s."storeListingVersionId"; + END IF; + END; + + -- Update submission status based on StoreListingSubmission status + UPDATE "StoreListingVersion" AS v + SET "submissionStatus" = s."Status" + FROM "StoreListingSubmission" AS s + WHERE v."id" = s."storeListingVersionId"; + + -- Update reviewedAt timestamps for versions with APPROVED or REJECTED status + UPDATE "StoreListingVersion" AS v + SET "reviewedAt" = s."updatedAt" + FROM "StoreListingSubmission" AS s + WHERE v."id" = s."storeListingVersionId" + AND s."Status" IN ('APPROVED', 'REJECTED'); +END; +$$; + +-- Drop the StoreListingSubmission table +DROP TABLE IF EXISTS "StoreListingSubmission"; + +-- Copy slugs from StoreListingVersion to StoreListing +WITH latest_versions AS ( + SELECT + "storeListingId", + "slug", + ROW_NUMBER() OVER (PARTITION BY "storeListingId" ORDER BY "version" DESC) as rn + FROM "StoreListingVersion" +) +UPDATE "StoreListing" sl +SET "slug" = lv."slug" +FROM latest_versions lv +WHERE sl."id" = lv."storeListingId" + AND lv.rn = 1; + +-- Make StoreListing.slug required and unique +ALTER TABLE "StoreListing" ALTER COLUMN "slug" SET NOT NULL; +CREATE UNIQUE INDEX "StoreListing_owningUserId_slug_key" ON "StoreListing"("owningUserId", "slug"); +DROP INDEX "StoreListing_owningUserId_idx"; + +-- Drop the slug column from StoreListingVersion since it's now on StoreListing +ALTER TABLE "StoreListingVersion" DROP COLUMN "slug"; + +-- Update both sides of the relation from one-to-one to one-to-many +-- The AgentGraph->StoreListingVersion relationship is now one-to-many + +-- Drop the unique constraint but add a non-unique index for query performance +ALTER TABLE "StoreListingVersion" DROP CONSTRAINT IF EXISTS "StoreListingVersion_agentId_agentVersion_key"; +CREATE INDEX IF NOT EXISTS "StoreListingVersion_agentId_agentVersion_idx" + ON "StoreListingVersion"("agentId", "agentVersion"); + +-- Set isApproved based on submissionStatus before removing it +UPDATE "StoreListingVersion" +SET "submissionStatus" = 'APPROVED' +WHERE "isApproved" = true; + +-- Drop the isApproved column from StoreListingVersion since it's redundant with submissionStatus +ALTER TABLE "StoreListingVersion" DROP COLUMN "isApproved"; + +-- Initialize hasApprovedVersion for existing StoreListing rows *** +-- This sets "hasApprovedVersion" = TRUE for any StoreListing +-- that has at least one corresponding version with "APPROVED" status. +UPDATE "StoreListing" sl +SET "hasApprovedVersion" = ( + SELECT COUNT(*) > 0 + FROM "StoreListingVersion" slv + WHERE slv."storeListingId" = sl.id + AND slv."submissionStatus" = 'APPROVED' + AND sl."agentId" = slv."agentId" + AND sl."agentVersion" = slv."agentVersion" +); + +-- Create new indexes +CREATE UNIQUE INDEX IF NOT EXISTS "StoreListing_activeVersionId_key" + ON "StoreListing"("activeVersionId"); + +CREATE INDEX IF NOT EXISTS "StoreListing_isDeleted_hasApprovedVersion_idx" + ON "StoreListing"("isDeleted", "hasApprovedVersion"); + +CREATE INDEX IF NOT EXISTS "StoreListingVersion_storeListingId_submissionStatus_isAvailable_idx" + ON "StoreListingVersion"("storeListingId", "submissionStatus", "isAvailable"); + +CREATE INDEX IF NOT EXISTS "StoreListingVersion_submissionStatus_idx" + ON "StoreListingVersion"("submissionStatus"); + +CREATE UNIQUE INDEX IF NOT EXISTS "StoreListingVersion_storeListingId_version_key" + ON "StoreListingVersion"("storeListingId", "version"); + +-- Add foreign keys +ALTER TABLE "StoreListing" +ADD CONSTRAINT "StoreListing_activeVersionId_fkey" +FOREIGN KEY ("activeVersionId") REFERENCES "StoreListingVersion"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +-- Add reviewer foreign key +ALTER TABLE "StoreListingVersion" +ADD CONSTRAINT "StoreListingVersion_reviewerId_fkey" +FOREIGN KEY ("reviewerId") REFERENCES "User"("id") +ON DELETE SET NULL ON UPDATE CASCADE; + +-- Add index for reviewer +CREATE INDEX IF NOT EXISTS "StoreListingVersion_reviewerId_idx" + ON "StoreListingVersion"("reviewerId"); + +-- DropIndex +DROP INDEX "StoreListingVersion_agentId_agentVersion_key"; + +-- RenameIndex +ALTER INDEX "StoreListingVersion_storeListingId_submissionStatus_isAvailable_idx" +RENAME TO "StoreListingVersion_storeListingId_submissionStatus_isAvail_idx"; + +-- Recreate the views with updated column references + +-- 1. Recreate StoreSubmission view +CREATE VIEW "StoreSubmission" AS +SELECT + sl.id AS listing_id, + sl."owningUserId" AS user_id, + slv."agentId" AS agent_id, + slv.version AS agent_version, + sl.slug, + COALESCE(slv.name, '') AS name, + slv."subHeading" AS sub_heading, + slv.description, + slv."imageUrls" AS image_urls, + slv."submittedAt" AS date_submitted, + slv."submissionStatus" AS status, + COALESCE(ar.run_count, 0::bigint) AS runs, + COALESCE(avg(sr.score::numeric), 0.0)::double precision AS rating, + -- Add the additional fields needed by the Pydantic model + slv.id AS store_listing_version_id, + slv."reviewerId" AS reviewer_id, + slv."reviewComments" AS review_comments, + slv."internalComments" AS internal_comments, + slv."reviewedAt" AS reviewed_at, + slv."changesSummary" AS changes_summary +FROM "StoreListing" sl + JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id + LEFT JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id + LEFT JOIN ( + SELECT "AgentGraphExecution"."agentGraphId", count(*) AS run_count + FROM "AgentGraphExecution" + GROUP BY "AgentGraphExecution"."agentGraphId" + ) ar ON ar."agentGraphId" = slv."agentId" +WHERE sl."isDeleted" = false +GROUP BY sl.id, sl."owningUserId", slv.id, slv."agentId", slv.version, sl.slug, slv.name, + slv."subHeading", slv.description, slv."imageUrls", slv."submittedAt", + slv."submissionStatus", slv."reviewerId", slv."reviewComments", slv."internalComments", + slv."reviewedAt", slv."changesSummary", ar.run_count; + +-- 2. Recreate StoreAgent view +CREATE VIEW "StoreAgent" AS +WITH reviewstats AS ( + SELECT sl_1.id AS "storeListingId", + count(sr.id) AS review_count, + avg(sr.score::numeric) AS avg_rating + FROM "StoreListing" sl_1 + JOIN "StoreListingVersion" slv_1 + ON slv_1."storeListingId" = sl_1.id + JOIN "StoreListingReview" sr + ON sr."storeListingVersionId" = slv_1.id + WHERE sl_1."isDeleted" = false + GROUP BY sl_1.id +), agentruns AS ( + SELECT "AgentGraphExecution"."agentGraphId", + count(*) AS run_count + FROM "AgentGraphExecution" + GROUP BY "AgentGraphExecution"."agentGraphId" +) +SELECT sl.id AS listing_id, + slv.id AS "storeListingVersionId", + slv."createdAt" AS updated_at, + sl.slug, + COALESCE(slv.name, '') AS agent_name, + slv."videoUrl" AS agent_video, + COALESCE(slv."imageUrls", ARRAY[]::text[]) AS agent_image, + slv."isFeatured" AS featured, + p.username AS creator_username, + p."avatarUrl" AS creator_avatar, + slv."subHeading" AS sub_heading, + slv.description, + slv.categories, + COALESCE(ar.run_count, 0::bigint) AS runs, + COALESCE(rs.avg_rating, 0.0)::double precision AS rating, + array_agg(DISTINCT slv.version::text) AS versions + FROM "StoreListing" sl + JOIN "AgentGraph" a + ON sl."agentId" = a.id + AND sl."agentVersion" = a.version + LEFT JOIN "Profile" p + ON sl."owningUserId" = p."userId" + LEFT JOIN "StoreListingVersion" slv + ON slv."storeListingId" = sl.id + LEFT JOIN reviewstats rs + ON sl.id = rs."storeListingId" + LEFT JOIN agentruns ar + ON a.id = ar."agentGraphId" + WHERE sl."isDeleted" = false + AND sl."hasApprovedVersion" = true + AND slv."submissionStatus" = 'APPROVED' + GROUP BY sl.id, slv.id, sl.slug, slv."createdAt", slv.name, slv."videoUrl", + slv."imageUrls", slv."isFeatured", p.username, p."avatarUrl", + slv."subHeading", slv.description, slv.categories, ar.run_count, + rs.avg_rating; + +-- 3. Recreate Creator view +CREATE VIEW "Creator" AS +WITH agentstats AS ( + SELECT p_1.username, + count(DISTINCT sl.id) AS num_agents, + avg(COALESCE(sr.score, 0)::numeric) AS agent_rating, + sum(COALESCE(age.run_count, 0::bigint)) AS agent_runs + FROM "Profile" p_1 + LEFT JOIN "StoreListing" sl + ON sl."owningUserId" = p_1."userId" + LEFT JOIN "StoreListingVersion" slv + ON slv."storeListingId" = sl.id + LEFT JOIN "StoreListingReview" sr + ON sr."storeListingVersionId" = slv.id + LEFT JOIN ( + SELECT "AgentGraphExecution"."agentGraphId", + count(*) AS run_count + FROM "AgentGraphExecution" + GROUP BY "AgentGraphExecution"."agentGraphId" + ) age ON age."agentGraphId" = sl."agentId" + WHERE sl."isDeleted" = false + AND sl."hasApprovedVersion" = true + AND slv."submissionStatus" = 'APPROVED' + GROUP BY p_1.username +) +SELECT p.username, + p.name, + p."avatarUrl" AS avatar_url, + p.description, + array_agg(DISTINCT cats.c) FILTER (WHERE cats.c IS NOT NULL) AS top_categories, + p.links, + p."isFeatured" AS is_featured, + COALESCE(ast.num_agents, 0::bigint) AS num_agents, + COALESCE(ast.agent_rating, 0.0) AS agent_rating, + COALESCE(ast.agent_runs, 0::numeric) AS agent_runs + FROM "Profile" p + LEFT JOIN agentstats ast + ON ast.username = p.username + LEFT JOIN LATERAL ( + SELECT unnest(slv.categories) AS c + FROM "StoreListing" sl + JOIN "StoreListingVersion" slv + ON slv."storeListingId" = sl.id + WHERE sl."owningUserId" = p."userId" + AND sl."isDeleted" = false + AND sl."hasApprovedVersion" = true + AND slv."submissionStatus" = 'APPROVED' + ) cats ON true + GROUP BY p.username, p.name, p."avatarUrl", p.description, p.links, + p."isFeatured", ast.num_agents, ast.agent_rating, ast.agent_runs; + +COMMIT; diff --git a/autogpt_platform/backend/migrations/20250325100000_update_user_onboarding/migration.sql b/autogpt_platform/backend/migrations/20250325100000_update_user_onboarding/migration.sql new file mode 100644 index 000000000000..cf221c9d7430 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250325100000_update_user_onboarding/migration.sql @@ -0,0 +1,19 @@ +-- Create OnboardingStep enum +CREATE TYPE "OnboardingStep" AS ENUM ( + 'WELCOME', + 'USAGE_REASON', + 'INTEGRATIONS', + 'AGENT_CHOICE', + 'AGENT_NEW_RUN', + 'AGENT_INPUT', + 'CONGRATS' +); + +-- Modify the UserOnboarding table +ALTER TABLE "UserOnboarding" + DROP COLUMN "step", + DROP COLUMN "isCompleted", + DROP COLUMN "selectedAgentCreator", + DROP COLUMN "selectedAgentSlug", + ADD COLUMN "completedSteps" "OnboardingStep"[] DEFAULT '{}', + ADD COLUMN "selectedStoreListingVersionId" TEXT diff --git a/autogpt_platform/backend/migrations/20250407181043_refactor_store_relations/migration.sql b/autogpt_platform/backend/migrations/20250407181043_refactor_store_relations/migration.sql new file mode 100644 index 000000000000..ef67c19031e5 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250407181043_refactor_store_relations/migration.sql @@ -0,0 +1,35 @@ +/* +- Rename column StoreListing.agentId to agentGraphId +- Rename column StoreListing.agentVersion to agentGraphVersion +- Rename column StoreListingVersion.agentId to agentGraphId +- Rename column StoreListingVersion.agentVersion to agentGraphVersion +*/ + +-- Drop foreign key constraints on columns we're about to rename +ALTER TABLE "StoreListing" DROP CONSTRAINT "StoreListing_agentId_agentVersion_fkey"; +ALTER TABLE "StoreListingVersion" DROP CONSTRAINT "StoreListingVersion_agentId_agentVersion_fkey"; + +-- Drop indices on columns we're about to rename +DROP INDEX "StoreListing_agentId_key"; +DROP INDEX "StoreListingVersion_agentId_agentVersion_idx"; + +-- Rename columns +ALTER TABLE "StoreListing" RENAME COLUMN "agentId" TO "agentGraphId"; +ALTER TABLE "StoreListing" RENAME COLUMN "agentVersion" TO "agentGraphVersion"; +ALTER TABLE "StoreListingVersion" RENAME COLUMN "agentId" TO "agentGraphId"; +ALTER TABLE "StoreListingVersion" RENAME COLUMN "agentVersion" TO "agentGraphVersion"; + +-- Re-create indices with updated name on renamed columns +CREATE UNIQUE INDEX "StoreListing_agentGraphId_key" ON "StoreListing"("agentGraphId"); +CREATE INDEX "StoreListingVersion_agentGraphId_agentGraphVersion_idx" ON "StoreListingVersion"("agentGraphId", "agentGraphVersion"); + +-- Re-create foreign key constraints with updated name on renamed columns +ALTER TABLE "StoreListing" ADD CONSTRAINT "StoreListing_agentGraphId_agentGraphVersion_fkey" +FOREIGN KEY ("agentGraphId", "agentGraphVersion") REFERENCES "AgentGraph"("id", "version") +ON DELETE CASCADE +ON UPDATE CASCADE; + +ALTER TABLE "StoreListingVersion" ADD CONSTRAINT "StoreListingVersion_agentGraphId_agentGraphVersion_fkey" +FOREIGN KEY ("agentGraphId", "agentGraphVersion") REFERENCES "AgentGraph"("id", "version") +ON DELETE RESTRICT +ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/migrations/20250411130000_update_onboarding_step/migration.sql b/autogpt_platform/backend/migrations/20250411130000_update_onboarding_step/migration.sql new file mode 100644 index 000000000000..d4b80091e0dd --- /dev/null +++ b/autogpt_platform/backend/migrations/20250411130000_update_onboarding_step/migration.sql @@ -0,0 +1,16 @@ +-- Modify the OnboardingStep enum +ALTER TYPE "OnboardingStep" ADD VALUE 'GET_RESULTS'; +ALTER TYPE "OnboardingStep" ADD VALUE 'MARKETPLACE_VISIT'; +ALTER TYPE "OnboardingStep" ADD VALUE 'MARKETPLACE_ADD_AGENT'; +ALTER TYPE "OnboardingStep" ADD VALUE 'MARKETPLACE_RUN_AGENT'; +ALTER TYPE "OnboardingStep" ADD VALUE 'BUILDER_OPEN'; +ALTER TYPE "OnboardingStep" ADD VALUE 'BUILDER_SAVE_AGENT'; +ALTER TYPE "OnboardingStep" ADD VALUE 'BUILDER_RUN_AGENT'; + +-- Modify the UserOnboarding table +ALTER TABLE "UserOnboarding" + ADD COLUMN "updatedAt" TIMESTAMP(3), + ADD COLUMN "notificationDot" BOOLEAN NOT NULL DEFAULT true, + ADD COLUMN "notified" "OnboardingStep"[] DEFAULT '{}', + ADD COLUMN "rewardedFor" "OnboardingStep"[] DEFAULT '{}', + ADD COLUMN "onboardingAgentExecutionId" TEXT diff --git a/autogpt_platform/backend/migrations/20250416140000_make_arrays_not_null/migration.sql b/autogpt_platform/backend/migrations/20250416140000_make_arrays_not_null/migration.sql new file mode 100644 index 000000000000..89aacc3fea45 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250416140000_make_arrays_not_null/migration.sql @@ -0,0 +1,56 @@ +-- Backfill nulls with empty arrays +UPDATE "UserOnboarding" +SET "integrations" = ARRAY[]::TEXT[] +WHERE "integrations" IS NULL; + +UPDATE "UserOnboarding" +SET "completedSteps" = '{}' +WHERE "completedSteps" IS NULL; + +UPDATE "UserOnboarding" +SET "notified" = '{}' +WHERE "notified" IS NULL; + +UPDATE "UserOnboarding" +SET "rewardedFor" = '{}' +WHERE "rewardedFor" IS NULL; + +UPDATE "IntegrationWebhook" +SET "events" = ARRAY[]::TEXT[] +WHERE "events" IS NULL; + +UPDATE "APIKey" +SET "permissions" = '{}' +WHERE "permissions" IS NULL; + +UPDATE "Profile" +SET "links" = ARRAY[]::TEXT[] +WHERE "links" IS NULL; + +UPDATE "StoreListingVersion" +SET "imageUrls" = ARRAY[]::TEXT[] +WHERE "imageUrls" IS NULL; + +UPDATE "StoreListingVersion" +SET "categories" = ARRAY[]::TEXT[] +WHERE "categories" IS NULL; + +-- Enforce NOT NULL constraints +ALTER TABLE "UserOnboarding" + ALTER COLUMN "integrations" SET NOT NULL, + ALTER COLUMN "completedSteps" SET NOT NULL, + ALTER COLUMN "notified" SET NOT NULL, + ALTER COLUMN "rewardedFor" SET NOT NULL; + +ALTER TABLE "IntegrationWebhook" + ALTER COLUMN "events" SET NOT NULL; + +ALTER TABLE "APIKey" + ALTER COLUMN "permissions" SET NOT NULL; + +ALTER TABLE "Profile" + ALTER COLUMN "links" SET NOT NULL; + +ALTER TABLE "StoreListingVersion" + ALTER COLUMN "imageUrls" SET NOT NULL, + ALTER COLUMN "categories" SET NOT NULL; diff --git a/autogpt_platform/backend/migrations/20250422125822_add_forked_relation/migration.sql b/autogpt_platform/backend/migrations/20250422125822_add_forked_relation/migration.sql new file mode 100644 index 000000000000..cc3e1256bb31 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250422125822_add_forked_relation/migration.sql @@ -0,0 +1,7 @@ +-- AlterTable +ALTER TABLE "AgentGraph" + ADD COLUMN "forkedFromId" TEXT, + ADD COLUMN "forkedFromVersion" INTEGER; + +-- AddForeignKey +ALTER TABLE "AgentGraph" ADD CONSTRAINT "AgentGraph_forkedFromId_forkedFromVersion_fkey" FOREIGN KEY ("forkedFromId", "forkedFromVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/migrations/20250507025350_execution_created_at_index/migration.sql b/autogpt_platform/backend/migrations/20250507025350_execution_created_at_index/migration.sql new file mode 100644 index 000000000000..71e61d23949b --- /dev/null +++ b/autogpt_platform/backend/migrations/20250507025350_execution_created_at_index/migration.sql @@ -0,0 +1,5 @@ +-- CreateIndex +CREATE INDEX "AgentGraphExecution_createdAt_idx" ON "AgentGraphExecution"("createdAt"); + +-- CreateIndex +CREATE INDEX "AgentNodeExecution_addedTime_idx" ON "AgentNodeExecution"("addedTime"); diff --git a/autogpt_platform/backend/migrations/20250512104735_rename_agent_executor_node_input/migration.sql b/autogpt_platform/backend/migrations/20250512104735_rename_agent_executor_node_input/migration.sql new file mode 100644 index 000000000000..a5d7dc496ded --- /dev/null +++ b/autogpt_platform/backend/migrations/20250512104735_rename_agent_executor_node_input/migration.sql @@ -0,0 +1,9 @@ +-- Rename 'data' input to 'inputs' on all Agent Executor nodes +UPDATE "AgentNode" AS node +SET "constantInput" = jsonb_set( + "constantInput", + '{inputs}', + "constantInput"->'data' + ) - 'data' +WHERE node."agentBlockId" = 'e189baac-8c20-45a1-94a7-55177ea42565' +AND node."constantInput" ? 'data'; diff --git a/autogpt_platform/backend/migrations/20250527091422_node_execution_indexes/migration.sql b/autogpt_platform/backend/migrations/20250527091422_node_execution_indexes/migration.sql new file mode 100644 index 000000000000..c3e9b0a29f24 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250527091422_node_execution_indexes/migration.sql @@ -0,0 +1,20 @@ +-- DropIndex +DROP INDEX "AgentNodeExecution_addedTime_idx"; + +-- DropIndex +DROP INDEX "AgentNodeExecution_agentGraphExecutionId_idx"; + +-- DropIndex +DROP INDEX "AgentNodeExecution_agentNodeId_idx"; + +-- CreateIndex +CREATE INDEX "AgentNodeExecution_agentGraphExecutionId_agentNodeId_execut_idx" ON "AgentNodeExecution"("agentGraphExecutionId", "agentNodeId", "executionStatus"); + +-- CreateIndex +CREATE INDEX "AgentNodeExecution_addedTime_queuedTime_idx" ON "AgentNodeExecution"("addedTime", "queuedTime"); + +-- CreateIndex +CREATE INDEX "AgentNodeExecutionInputOutput_name_time_idx" ON "AgentNodeExecutionInputOutput"("name", "time"); + +-- CreateIndex +CREATE INDEX "NotificationEvent_userNotificationBatchId_idx" ON "NotificationEvent"("userNotificationBatchId"); diff --git a/autogpt_platform/backend/migrations/20250528092000_onboarding_add_runs/migration.sql b/autogpt_platform/backend/migrations/20250528092000_onboarding_add_runs/migration.sql new file mode 100644 index 000000000000..544e51b8f84e --- /dev/null +++ b/autogpt_platform/backend/migrations/20250528092000_onboarding_add_runs/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "OnboardingStep" ADD VALUE 'RUN_AGENTS'; + +-- AlterTable +ALTER TABLE "UserOnboarding" ADD COLUMN "agentRuns" INTEGER NOT NULL DEFAULT 0; diff --git a/autogpt_platform/backend/migrations/20250604130249_optimise_store_agent_and_creator_views/migration.sql b/autogpt_platform/backend/migrations/20250604130249_optimise_store_agent_and_creator_views/migration.sql new file mode 100644 index 000000000000..78c6a1e7890a --- /dev/null +++ b/autogpt_platform/backend/migrations/20250604130249_optimise_store_agent_and_creator_views/migration.sql @@ -0,0 +1,254 @@ +-- This migration creates materialized views for performance optimization +-- +-- IMPORTANT: For production environments, pg_cron is REQUIRED for automatic refresh +-- Prerequisites for production: +-- 1. pg_cron extension must be installed: CREATE EXTENSION pg_cron; +-- 2. pg_cron must be configured in postgresql.conf: +-- shared_preload_libraries = 'pg_cron' +-- cron.database_name = 'your_database_name' +-- +-- For development environments without pg_cron: +-- The migration will succeed but you must manually refresh views with: +-- SELECT refresh_store_materialized_views(); + +-- Check if pg_cron extension is installed and set a flag +DO $$ +DECLARE + has_pg_cron BOOLEAN; +BEGIN + SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') INTO has_pg_cron; + + IF NOT has_pg_cron THEN + RAISE WARNING 'pg_cron extension is not installed!'; + RAISE WARNING 'Materialized views will be created but WILL NOT refresh automatically.'; + RAISE WARNING 'For production use, install pg_cron with: CREATE EXTENSION pg_cron;'; + RAISE WARNING 'For development, manually refresh with: SELECT refresh_store_materialized_views();'; + + -- For production deployments, uncomment the following line to make pg_cron mandatory: + -- RAISE EXCEPTION 'pg_cron is required for production deployments'; + END IF; + + -- Store the flag for later use in the migration + PERFORM set_config('migration.has_pg_cron', has_pg_cron::text, false); +END +$$; + +-- CreateIndex +-- Optimized: Only include owningUserId in index columns since isDeleted and hasApprovedVersion are in WHERE clause +CREATE INDEX IF NOT EXISTS "idx_store_listing_approved" ON "StoreListing"("owningUserId") WHERE "isDeleted" = false AND "hasApprovedVersion" = true; + +-- CreateIndex +-- Optimized: Only include storeListingId since submissionStatus is in WHERE clause +CREATE INDEX IF NOT EXISTS "idx_store_listing_version_status" ON "StoreListingVersion"("storeListingId") WHERE "submissionStatus" = 'APPROVED'; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_slv_categories_gin" ON "StoreListingVersion" USING GIN ("categories") WHERE "submissionStatus" = 'APPROVED'; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_slv_agent" ON "StoreListingVersion"("agentGraphId", "agentGraphVersion") WHERE "submissionStatus" = 'APPROVED'; + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_store_listing_review_version" ON "StoreListingReview"("storeListingVersionId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_agent_graph_execution_agent" ON "AgentGraphExecution"("agentGraphId"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_profile_user" ON "Profile"("userId"); + +-- Additional performance indexes +CREATE INDEX IF NOT EXISTS "idx_store_listing_version_approved_listing" ON "StoreListingVersion"("storeListingId", "version") WHERE "submissionStatus" = 'APPROVED'; + +-- Create materialized view for agent run counts +CREATE MATERIALIZED VIEW IF NOT EXISTS "mv_agent_run_counts" AS +SELECT + "agentGraphId", + COUNT(*) AS run_count +FROM "AgentGraphExecution" +GROUP BY "agentGraphId"; + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "idx_mv_agent_run_counts" ON "mv_agent_run_counts"("agentGraphId"); + +-- Create materialized view for review statistics +CREATE MATERIALIZED VIEW IF NOT EXISTS "mv_review_stats" AS +SELECT + sl.id AS "storeListingId", + COUNT(sr.id) AS review_count, + AVG(sr.score::numeric) AS avg_rating +FROM "StoreListing" sl +JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id +LEFT JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id +WHERE sl."isDeleted" = false + AND slv."submissionStatus" = 'APPROVED' +GROUP BY sl.id; + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "idx_mv_review_stats" ON "mv_review_stats"("storeListingId"); + +-- DropForeignKey (if any exist on the views) +-- None needed as views don't have foreign keys + +-- DropView +DROP VIEW IF EXISTS "Creator"; + +-- DropView +DROP VIEW IF EXISTS "StoreAgent"; + +-- CreateView +CREATE OR REPLACE VIEW "StoreAgent" AS +WITH agent_versions AS ( + SELECT + "storeListingId", + array_agg(DISTINCT version::text ORDER BY version::text) AS versions + FROM "StoreListingVersion" + WHERE "submissionStatus" = 'APPROVED' + GROUP BY "storeListingId" +) +SELECT + sl.id AS listing_id, + slv.id AS "storeListingVersionId", + slv."createdAt" AS updated_at, + sl.slug, + COALESCE(slv.name, '') AS agent_name, + slv."videoUrl" AS agent_video, + COALESCE(slv."imageUrls", ARRAY[]::text[]) AS agent_image, + slv."isFeatured" AS featured, + p.username AS creator_username, + p."avatarUrl" AS creator_avatar, + slv."subHeading" AS sub_heading, + slv.description, + slv.categories, + COALESCE(ar.run_count, 0::bigint) AS runs, + COALESCE(rs.avg_rating, 0.0)::double precision AS rating, + COALESCE(av.versions, ARRAY[slv.version::text]) AS versions +FROM "StoreListing" sl +INNER JOIN "StoreListingVersion" slv + ON slv."storeListingId" = sl.id + AND slv."submissionStatus" = 'APPROVED' +JOIN "AgentGraph" a + ON slv."agentGraphId" = a.id + AND slv."agentGraphVersion" = a.version +LEFT JOIN "Profile" p + ON sl."owningUserId" = p."userId" +LEFT JOIN "mv_review_stats" rs + ON sl.id = rs."storeListingId" +LEFT JOIN "mv_agent_run_counts" ar + ON a.id = ar."agentGraphId" +LEFT JOIN agent_versions av + ON sl.id = av."storeListingId" +WHERE sl."isDeleted" = false + AND sl."hasApprovedVersion" = true; + +-- CreateView +CREATE OR REPLACE VIEW "Creator" AS +WITH creator_listings AS ( + SELECT + sl."owningUserId", + sl.id AS listing_id, + slv."agentGraphId", + slv.categories, + sr.score, + ar.run_count + FROM "StoreListing" sl + INNER JOIN "StoreListingVersion" slv + ON slv."storeListingId" = sl.id + AND slv."submissionStatus" = 'APPROVED' + LEFT JOIN "StoreListingReview" sr + ON sr."storeListingVersionId" = slv.id + LEFT JOIN "mv_agent_run_counts" ar + ON ar."agentGraphId" = slv."agentGraphId" + WHERE sl."isDeleted" = false + AND sl."hasApprovedVersion" = true +), +creator_stats AS ( + SELECT + cl."owningUserId", + COUNT(DISTINCT cl.listing_id) AS num_agents, + AVG(COALESCE(cl.score, 0)::numeric) AS agent_rating, + SUM(DISTINCT COALESCE(cl.run_count, 0)) AS agent_runs, + array_agg(DISTINCT cat ORDER BY cat) FILTER (WHERE cat IS NOT NULL) AS all_categories + FROM creator_listings cl + LEFT JOIN LATERAL unnest(COALESCE(cl.categories, ARRAY[]::text[])) AS cat ON true + GROUP BY cl."owningUserId" +) +SELECT + p.username, + p.name, + p."avatarUrl" AS avatar_url, + p.description, + cs.all_categories AS top_categories, + p.links, + p."isFeatured" AS is_featured, + COALESCE(cs.num_agents, 0::bigint) AS num_agents, + COALESCE(cs.agent_rating, 0.0) AS agent_rating, + COALESCE(cs.agent_runs, 0::numeric) AS agent_runs +FROM "Profile" p +LEFT JOIN creator_stats cs ON cs."owningUserId" = p."userId"; + +-- Create refresh function that works with the current schema +CREATE OR REPLACE FUNCTION refresh_store_materialized_views() +RETURNS void +LANGUAGE plpgsql +AS $$ +DECLARE + current_schema_name text; +BEGIN + -- Get the current schema + current_schema_name := current_schema(); + + -- Use CONCURRENTLY for better performance during refresh + EXECUTE format('REFRESH MATERIALIZED VIEW CONCURRENTLY %I."mv_agent_run_counts"', current_schema_name); + EXECUTE format('REFRESH MATERIALIZED VIEW CONCURRENTLY %I."mv_review_stats"', current_schema_name); + RAISE NOTICE 'Materialized views refreshed in schema % at %', current_schema_name, NOW(); +EXCEPTION + WHEN OTHERS THEN + -- Fallback to non-concurrent refresh if concurrent fails + EXECUTE format('REFRESH MATERIALIZED VIEW %I."mv_agent_run_counts"', current_schema_name); + EXECUTE format('REFRESH MATERIALIZED VIEW %I."mv_review_stats"', current_schema_name); + RAISE NOTICE 'Materialized views refreshed (non-concurrent) in schema % at % due to: %', current_schema_name, NOW(), SQLERRM; +END; +$$; + +-- Initial refresh of materialized views +SELECT refresh_store_materialized_views(); + +-- Schedule automatic refresh every 15 minutes (only if pg_cron is available) +DO $$ +DECLARE + has_pg_cron BOOLEAN; + current_schema_name text; + job_name text; +BEGIN + -- Get the flag we set earlier + has_pg_cron := current_setting('migration.has_pg_cron', true)::boolean; + + -- Get current schema name + current_schema_name := current_schema(); + + -- Create a unique job name for this schema + job_name := format('refresh-store-views-%s', current_schema_name); + + IF has_pg_cron THEN + -- Try to unschedule existing job (ignore errors if it doesn't exist) + BEGIN + PERFORM cron.unschedule(job_name); + EXCEPTION WHEN OTHERS THEN + -- Job doesn't exist, that's fine + NULL; + END; + + -- Schedule the refresh job with schema-specific command + PERFORM cron.schedule( + job_name, + '*/15 * * * *', + format('SELECT %I.refresh_store_materialized_views();', current_schema_name) + ); + RAISE NOTICE 'Scheduled automatic refresh of materialized views every 15 minutes for schema %', current_schema_name; + ELSE + RAISE WARNING '⚠️ Automatic refresh NOT configured - pg_cron is not available'; + RAISE WARNING '⚠️ You must manually refresh views with: SELECT refresh_store_materialized_views();'; + RAISE WARNING '⚠️ Or install pg_cron for automatic refresh in production'; + END IF; +END; +$$; \ No newline at end of file diff --git a/autogpt_platform/backend/migrations/20250604130249_optimise_store_agent_and_creator_views/rollback.sql b/autogpt_platform/backend/migrations/20250604130249_optimise_store_agent_and_creator_views/rollback.sql new file mode 100644 index 000000000000..4584dbe2d05e --- /dev/null +++ b/autogpt_platform/backend/migrations/20250604130249_optimise_store_agent_and_creator_views/rollback.sql @@ -0,0 +1,155 @@ +-- Unschedule cron job (if it exists) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN + PERFORM cron.unschedule('refresh-store-views'); + RAISE NOTICE 'Unscheduled automatic refresh of materialized views'; + END IF; +EXCEPTION + WHEN OTHERS THEN + RAISE NOTICE 'Could not unschedule cron job (may not exist): %', SQLERRM; +END; +$$; + +-- DropView +DROP VIEW IF EXISTS "Creator"; + +-- DropView +DROP VIEW IF EXISTS "StoreAgent"; + +-- CreateView (restore original StoreAgent) +CREATE VIEW "StoreAgent" AS +WITH reviewstats AS ( + SELECT sl_1.id AS "storeListingId", + count(sr.id) AS review_count, + avg(sr.score::numeric) AS avg_rating + FROM "StoreListing" sl_1 + JOIN "StoreListingVersion" slv_1 + ON slv_1."storeListingId" = sl_1.id + JOIN "StoreListingReview" sr + ON sr."storeListingVersionId" = slv_1.id + WHERE sl_1."isDeleted" = false + GROUP BY sl_1.id +), agentruns AS ( + SELECT "AgentGraphExecution"."agentGraphId", + count(*) AS run_count + FROM "AgentGraphExecution" + GROUP BY "AgentGraphExecution"."agentGraphId" +) +SELECT sl.id AS listing_id, + slv.id AS "storeListingVersionId", + slv."createdAt" AS updated_at, + sl.slug, + COALESCE(slv.name, '') AS agent_name, + slv."videoUrl" AS agent_video, + COALESCE(slv."imageUrls", ARRAY[]::text[]) AS agent_image, + slv."isFeatured" AS featured, + p.username AS creator_username, + p."avatarUrl" AS creator_avatar, + slv."subHeading" AS sub_heading, + slv.description, + slv.categories, + COALESCE(ar.run_count, 0::bigint) AS runs, + COALESCE(rs.avg_rating, 0.0)::double precision AS rating, + array_agg(DISTINCT slv.version::text) AS versions + FROM "StoreListing" sl + JOIN "StoreListingVersion" slv + ON slv."storeListingId" = sl.id + JOIN "AgentGraph" a + ON slv."agentGraphId" = a.id + AND slv."agentGraphVersion" = a.version + LEFT JOIN "Profile" p + ON sl."owningUserId" = p."userId" + LEFT JOIN reviewstats rs + ON sl.id = rs."storeListingId" + LEFT JOIN agentruns ar + ON a.id = ar."agentGraphId" + WHERE sl."isDeleted" = false + AND sl."hasApprovedVersion" = true + AND slv."submissionStatus" = 'APPROVED' + GROUP BY sl.id, slv.id, sl.slug, slv."createdAt", slv.name, slv."videoUrl", + slv."imageUrls", slv."isFeatured", p.username, p."avatarUrl", + slv."subHeading", slv.description, slv.categories, ar.run_count, + rs.avg_rating; + +-- CreateView (restore original Creator) +CREATE VIEW "Creator" AS +WITH agentstats AS ( + SELECT p_1.username, + count(DISTINCT sl.id) AS num_agents, + avg(COALESCE(sr.score, 0)::numeric) AS agent_rating, + sum(COALESCE(age.run_count, 0::bigint)) AS agent_runs + FROM "Profile" p_1 + LEFT JOIN "StoreListing" sl + ON sl."owningUserId" = p_1."userId" + LEFT JOIN "StoreListingVersion" slv + ON slv."storeListingId" = sl.id + LEFT JOIN "StoreListingReview" sr + ON sr."storeListingVersionId" = slv.id + LEFT JOIN ( + SELECT "AgentGraphExecution"."agentGraphId", + count(*) AS run_count + FROM "AgentGraphExecution" + GROUP BY "AgentGraphExecution"."agentGraphId" + ) age ON age."agentGraphId" = slv."agentGraphId" + WHERE sl."isDeleted" = false + AND sl."hasApprovedVersion" = true + AND slv."submissionStatus" = 'APPROVED' + GROUP BY p_1.username +) +SELECT p.username, + p.name, + p."avatarUrl" AS avatar_url, + p.description, + array_agg(DISTINCT cats.c) FILTER (WHERE cats.c IS NOT NULL) AS top_categories, + p.links, + p."isFeatured" AS is_featured, + COALESCE(ast.num_agents, 0::bigint) AS num_agents, + COALESCE(ast.agent_rating, 0.0) AS agent_rating, + COALESCE(ast.agent_runs, 0::numeric) AS agent_runs + FROM "Profile" p + LEFT JOIN agentstats ast + ON ast.username = p.username + LEFT JOIN LATERAL ( + SELECT unnest(slv.categories) AS c + FROM "StoreListing" sl + JOIN "StoreListingVersion" slv + ON slv."storeListingId" = sl.id + WHERE sl."owningUserId" = p."userId" + AND sl."isDeleted" = false + AND sl."hasApprovedVersion" = true + AND slv."submissionStatus" = 'APPROVED' + ) cats ON true + GROUP BY p.username, p.name, p."avatarUrl", p.description, p.links, + p."isFeatured", ast.num_agents, ast.agent_rating, ast.agent_runs; + +-- Drop function +DROP FUNCTION IF EXISTS platform.refresh_store_materialized_views(); + +-- Drop materialized views +DROP MATERIALIZED VIEW IF EXISTS "mv_review_stats"; +DROP MATERIALIZED VIEW IF EXISTS "mv_agent_run_counts"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_profile_user"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_agent_graph_execution_agent"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_store_listing_review_version"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_slv_agent"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_slv_categories_gin"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_store_listing_version_status"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_store_listing_approved"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_store_listing_version_approved_listing"; \ No newline at end of file diff --git a/autogpt_platform/backend/migrations/20250620000924_make_data_nullable/migration.sql b/autogpt_platform/backend/migrations/20250620000924_make_data_nullable/migration.sql new file mode 100644 index 000000000000..54a29f321431 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250620000924_make_data_nullable/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "AgentNodeExecutionInputOutput" ALTER COLUMN "data" DROP NOT NULL; diff --git a/autogpt_platform/backend/migrations/20250620140815_add_preset_webhook_relation/migration.sql b/autogpt_platform/backend/migrations/20250620140815_add_preset_webhook_relation/migration.sql new file mode 100644 index 000000000000..6720d796a9fb --- /dev/null +++ b/autogpt_platform/backend/migrations/20250620140815_add_preset_webhook_relation/migration.sql @@ -0,0 +1,5 @@ +-- Add webhookId column +ALTER TABLE "AgentPreset" ADD COLUMN "webhookId" TEXT; + +-- Add AgentPreset<->IntegrationWebhook relation +ALTER TABLE "AgentPreset" ADD CONSTRAINT "AgentPreset_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "IntegrationWebhook"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/autogpt_platform/backend/migrations/20250702224504_add_node_exec_kv_data/migration.sql b/autogpt_platform/backend/migrations/20250702224504_add_node_exec_kv_data/migration.sql new file mode 100644 index 000000000000..ffe1547ff84a --- /dev/null +++ b/autogpt_platform/backend/migrations/20250702224504_add_node_exec_kv_data/migration.sql @@ -0,0 +1,11 @@ +-- CreateTable +CREATE TABLE "AgentNodeExecutionKeyValueData" ( + "userId" TEXT NOT NULL, + "key" TEXT NOT NULL, + "agentNodeExecutionId" TEXT NOT NULL, + "data" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3), + + CONSTRAINT "AgentNodeExecutionKeyValueData_pkey" PRIMARY KEY ("userId","key") +); diff --git a/autogpt_platform/backend/migrations/20250721073830_add_preset_index/migration.sql b/autogpt_platform/backend/migrations/20250721073830_add_preset_index/migration.sql new file mode 100644 index 000000000000..1bc3b5293693 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250721073830_add_preset_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "AgentNodeExecutionInputOutput_agentPresetId_idx" ON "AgentNodeExecutionInputOutput"("agentPresetId"); diff --git a/autogpt_platform/backend/migrations/20250721081856_add_missing_fk_indexes_remove_unused_indexes/migration.sql b/autogpt_platform/backend/migrations/20250721081856_add_missing_fk_indexes_remove_unused_indexes/migration.sql new file mode 100644 index 000000000000..86f443b56d3b --- /dev/null +++ b/autogpt_platform/backend/migrations/20250721081856_add_missing_fk_indexes_remove_unused_indexes/migration.sql @@ -0,0 +1,109 @@ +-- DropIndex +DROP INDEX IF EXISTS "APIKey_key_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "APIKey_prefix_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "APIKey_status_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_agent_graph_execution_agent"; + +-- DropIndex +DROP INDEX IF EXISTS "AnalyticsDetails_type_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "AnalyticsMetrics_userId_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "IntegrationWebhook_userId_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "Profile_username_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_store_listing_review_version"; + +-- DropIndex +DROP INDEX IF EXISTS "User_email_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "User_id_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "UserOnboarding_userId_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_store_listing_version_status"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_slv_agent"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_store_listing_version_approved_listing"; + +-- DropIndex +DROP INDEX IF EXISTS "StoreListing_agentId_owningUserId_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "StoreListing_isDeleted_isApproved_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "StoreListing_isDeleted_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "StoreListingVersion_agentId_agentVersion_isDeleted_idx"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_store_listing_approved"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_slv_categories_gin"; + +-- DropIndex +DROP INDEX IF EXISTS "idx_profile_user"; + +-- CreateIndex +CREATE INDEX "APIKey_prefix_name_idx" ON "APIKey"("prefix", "name"); + +-- CreateIndex +CREATE INDEX "AgentGraph_forkedFromId_forkedFromVersion_idx" ON "AgentGraph"("forkedFromId", "forkedFromVersion"); + +-- CreateIndex +CREATE INDEX "AgentGraphExecution_agentPresetId_idx" ON "AgentGraphExecution"("agentPresetId"); + +-- CreateIndex +CREATE INDEX "AgentNodeExecution_agentNodeId_executionStatus_idx" ON "AgentNodeExecution"("agentNodeId", "executionStatus"); + +-- CreateIndex +CREATE INDEX "AgentPreset_agentGraphId_agentGraphVersion_idx" ON "AgentPreset"("agentGraphId", "agentGraphVersion"); + +-- CreateIndex +CREATE INDEX "AgentPreset_webhookId_idx" ON "AgentPreset"("webhookId"); + +-- CreateIndex +CREATE INDEX "LibraryAgent_agentGraphId_agentGraphVersion_idx" ON "LibraryAgent"("agentGraphId", "agentGraphVersion"); + +-- CreateIndex +CREATE INDEX "LibraryAgent_creatorId_idx" ON "LibraryAgent"("creatorId"); + +-- CreateIndex +CREATE INDEX "StoreListing_agentGraphId_agentGraphVersion_idx" ON "StoreListing"("agentGraphId", "agentGraphVersion"); + +-- CreateIndex +CREATE INDEX "StoreListingReview_reviewByUserId_idx" ON "StoreListingReview"("reviewByUserId"); + +-- CreateIndex (Materialized View Performance Indexes) +CREATE INDEX IF NOT EXISTS "idx_mv_review_stats_rating" ON "mv_review_stats" ("avg_rating" DESC); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "idx_mv_review_stats_count" ON "mv_review_stats" ("review_count" DESC); + +-- RenameIndex (only if exists) +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM pg_indexes WHERE indexname = 'analyticsDetails') THEN + ALTER INDEX "analyticsDetails" RENAME TO "AnalyticsDetails_userId_type_idx"; + END IF; +END $$; diff --git a/autogpt_platform/backend/migrations/20250805111135_add_video_url_and_categories_to_store_submission_view/migration.sql b/autogpt_platform/backend/migrations/20250805111135_add_video_url_and_categories_to_store_submission_view/migration.sql new file mode 100644 index 000000000000..c5e8962df212 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250805111135_add_video_url_and_categories_to_store_submission_view/migration.sql @@ -0,0 +1,41 @@ +-- Drop the existing view +DROP VIEW IF EXISTS "StoreSubmission"; + +-- Recreate the view with the new fields +CREATE VIEW "StoreSubmission" AS +SELECT + sl.id AS listing_id, + sl."owningUserId" AS user_id, + slv."agentGraphId" AS agent_id, + slv.version AS agent_version, + sl.slug, + COALESCE(slv.name, '') AS name, + slv."subHeading" AS sub_heading, + slv.description, + slv."imageUrls" AS image_urls, + slv."submittedAt" AS date_submitted, + slv."submissionStatus" AS status, + COALESCE(ar.run_count, 0::bigint) AS runs, + COALESCE(avg(sr.score::numeric), 0.0)::double precision AS rating, + slv.id AS store_listing_version_id, + slv."reviewerId" AS reviewer_id, + slv."reviewComments" AS review_comments, + slv."internalComments" AS internal_comments, + slv."reviewedAt" AS reviewed_at, + slv."changesSummary" AS changes_summary, + -- Add the two new fields: + slv."videoUrl" AS video_url, + slv.categories +FROM "StoreListing" sl + JOIN "StoreListingVersion" slv ON slv."storeListingId" = sl.id + LEFT JOIN "StoreListingReview" sr ON sr."storeListingVersionId" = slv.id + LEFT JOIN ( + SELECT "AgentGraphExecution"."agentGraphId", count(*) AS run_count + FROM "AgentGraphExecution" + GROUP BY "AgentGraphExecution"."agentGraphId" + ) ar ON ar."agentGraphId" = slv."agentGraphId" +WHERE sl."isDeleted" = false +GROUP BY sl.id, sl."owningUserId", slv.id, slv."agentGraphId", slv.version, sl.slug, slv.name, + slv."subHeading", slv.description, slv."imageUrls", slv."submittedAt", + slv."submissionStatus", slv."reviewerId", slv."reviewComments", slv."internalComments", + slv."reviewedAt", slv."changesSummary", slv."videoUrl", slv.categories, ar.run_count; \ No newline at end of file diff --git a/autogpt_platform/backend/migrations/20250819163527_add_user_timezone/migration.sql b/autogpt_platform/backend/migrations/20250819163527_add_user_timezone/migration.sql new file mode 100644 index 000000000000..87bbed7dfb0e --- /dev/null +++ b/autogpt_platform/backend/migrations/20250819163527_add_user_timezone/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "timezone" TEXT NOT NULL DEFAULT 'not-set' + CHECK (timezone = 'not-set' OR now() AT TIME ZONE timezone IS NOT NULL); diff --git a/autogpt_platform/backend/migrations/20250824000317_add_notifications_for_approved_and_denied_agents/migration.sql b/autogpt_platform/backend/migrations/20250824000317_add_notifications_for_approved_and_denied_agents/migration.sql new file mode 100644 index 000000000000..5e56f1f96429 --- /dev/null +++ b/autogpt_platform/backend/migrations/20250824000317_add_notifications_for_approved_and_denied_agents/migration.sql @@ -0,0 +1,14 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "NotificationType" ADD VALUE 'AGENT_APPROVED'; +ALTER TYPE "NotificationType" ADD VALUE 'AGENT_REJECTED'; + +-- AlterTable +ALTER TABLE "User" ADD COLUMN "notifyOnAgentApproved" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "notifyOnAgentRejected" BOOLEAN NOT NULL DEFAULT true; diff --git a/autogpt_platform/backend/poetry.lock b/autogpt_platform/backend/poetry.lock index e36626de911f..f429f057e504 100644 --- a/autogpt_platform/backend/poetry.lock +++ b/autogpt_platform/backend/poetry.lock @@ -1,14 +1,15 @@ -# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. [[package]] name = "aio-pika" -version = "9.5.4" +version = "9.5.5" description = "Wrapper around the aiormq for asyncio and humans" optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "aio_pika-9.5.4-py3-none-any.whl", hash = "sha256:a308f904cd4f97e2705662fe23cde37c6c7eddde0e1ea17467028fac6c474e15"}, - {file = "aio_pika-9.5.4.tar.gz", hash = "sha256:5a1bad96a75fa5ac3aa5b2bbd3eca971ea9abda70693e4334e6e629639f8a8fc"}, + {file = "aio_pika-9.5.5-py3-none-any.whl", hash = "sha256:94e0ac3666398d6a28b0c3b530c1febf4c6d4ececb345620727cfd7bfe1c02e0"}, + {file = "aio_pika-9.5.5.tar.gz", hash = "sha256:3d2f25838860fa7e209e21fc95555f558401f9b49a832897419489f1c9e1d6a4"}, ] [package.dependencies] @@ -16,105 +17,156 @@ aiormq = ">=6.8,<6.9" exceptiongroup = ">=1,<2" yarl = "*" +[[package]] +name = "aioclamd" +version = "1.0.0" +description = "Asynchronous client for virus scanning with ClamAV" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "aioclamd-1.0.0-py3-none-any.whl", hash = "sha256:4727da3953a4b38be4c2de1acb6b3bb3c94c1c171dcac780b80234ee6253f3d9"}, + {file = "aioclamd-1.0.0.tar.gz", hash = "sha256:7b14e94e3a2285cc89e2f4d434e2a01f322d3cb95476ce2dda015a7980876047"}, +] + +[[package]] +name = "aiodns" +version = "3.5.0" +description = "Simple DNS resolver for asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "aiodns-3.5.0-py3-none-any.whl", hash = "sha256:6d0404f7d5215849233f6ee44854f2bb2481adf71b336b2279016ea5990ca5c5"}, + {file = "aiodns-3.5.0.tar.gz", hash = "sha256:11264edbab51896ecf546c18eb0dd56dff0428c6aa6d2cd87e643e07300eb310"}, +] + +[package.dependencies] +pycares = ">=4.9.0" + +[[package]] +name = "aiofiles" +version = "24.1.0" +description = "File support for asyncio." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5"}, + {file = "aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c"}, +] + [[package]] name = "aiohappyeyeballs" -version = "2.4.4" +version = "2.6.1" description = "Happy Eyeballs for asyncio" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "aiohappyeyeballs-2.4.4-py3-none-any.whl", hash = "sha256:a980909d50efcd44795c4afeca523296716d50cd756ddca6af8c65b996e27de8"}, - {file = "aiohappyeyeballs-2.4.4.tar.gz", hash = "sha256:5fdd7d87889c63183afc18ce9271f9b0a7d32c2303e394468dd45d514a757745"}, + {file = "aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8"}, + {file = "aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558"}, ] [[package]] name = "aiohttp" -version = "3.11.10" +version = "3.12.14" description = "Async http client/server framework (asyncio)" optional = false python-versions = ">=3.9" -files = [ - {file = "aiohttp-3.11.10-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cbad88a61fa743c5d283ad501b01c153820734118b65aee2bd7dbb735475ce0d"}, - {file = "aiohttp-3.11.10-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:80886dac673ceaef499de2f393fc80bb4481a129e6cb29e624a12e3296cc088f"}, - {file = "aiohttp-3.11.10-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61b9bae80ed1f338c42f57c16918853dc51775fb5cb61da70d590de14d8b5fb4"}, - {file = "aiohttp-3.11.10-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e2e576caec5c6a6b93f41626c9c02fc87cd91538b81a3670b2e04452a63def6"}, - {file = "aiohttp-3.11.10-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02c13415b5732fb6ee7ff64583a5e6ed1c57aa68f17d2bda79c04888dfdc2769"}, - {file = "aiohttp-3.11.10-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4cfce37f31f20800a6a6620ce2cdd6737b82e42e06e6e9bd1b36f546feb3c44f"}, - {file = "aiohttp-3.11.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bbbfff4c679c64e6e23cb213f57cc2c9165c9a65d63717108a644eb5a7398df"}, - {file = "aiohttp-3.11.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49c7dbbc1a559ae14fc48387a115b7d4bbc84b4a2c3b9299c31696953c2a5219"}, - {file = "aiohttp-3.11.10-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:68386d78743e6570f054fe7949d6cb37ef2b672b4d3405ce91fafa996f7d9b4d"}, - {file = "aiohttp-3.11.10-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9ef405356ba989fb57f84cac66f7b0260772836191ccefbb987f414bcd2979d9"}, - {file = "aiohttp-3.11.10-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5d6958671b296febe7f5f859bea581a21c1d05430d1bbdcf2b393599b1cdce77"}, - {file = "aiohttp-3.11.10-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:99b7920e7165be5a9e9a3a7f1b680f06f68ff0d0328ff4079e5163990d046767"}, - {file = "aiohttp-3.11.10-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0dc49f42422163efb7e6f1df2636fe3db72713f6cd94688e339dbe33fe06d61d"}, - {file = "aiohttp-3.11.10-cp310-cp310-win32.whl", hash = "sha256:40d1c7a7f750b5648642586ba7206999650208dbe5afbcc5284bcec6579c9b91"}, - {file = "aiohttp-3.11.10-cp310-cp310-win_amd64.whl", hash = "sha256:68ff6f48b51bd78ea92b31079817aff539f6c8fc80b6b8d6ca347d7c02384e33"}, - {file = "aiohttp-3.11.10-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:77c4aa15a89847b9891abf97f3d4048f3c2d667e00f8a623c89ad2dccee6771b"}, - {file = "aiohttp-3.11.10-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:909af95a72cedbefe5596f0bdf3055740f96c1a4baa0dd11fd74ca4de0b4e3f1"}, - {file = "aiohttp-3.11.10-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:386fbe79863eb564e9f3615b959e28b222259da0c48fd1be5929ac838bc65683"}, - {file = "aiohttp-3.11.10-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3de34936eb1a647aa919655ff8d38b618e9f6b7f250cc19a57a4bf7fd2062b6d"}, - {file = "aiohttp-3.11.10-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c9527819b29cd2b9f52033e7fb9ff08073df49b4799c89cb5754624ecd98299"}, - {file = "aiohttp-3.11.10-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65a96e3e03300b41f261bbfd40dfdbf1c301e87eab7cd61c054b1f2e7c89b9e8"}, - {file = "aiohttp-3.11.10-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f5635f7b74bcd4f6f72fcd85bea2154b323a9f05226a80bc7398d0c90763b0"}, - {file = "aiohttp-3.11.10-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:03b6002e20938fc6ee0918c81d9e776bebccc84690e2b03ed132331cca065ee5"}, - {file = "aiohttp-3.11.10-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6362cc6c23c08d18ddbf0e8c4d5159b5df74fea1a5278ff4f2c79aed3f4e9f46"}, - {file = "aiohttp-3.11.10-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3691ed7726fef54e928fe26344d930c0c8575bc968c3e239c2e1a04bd8cf7838"}, - {file = "aiohttp-3.11.10-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31d5093d3acd02b31c649d3a69bb072d539d4c7659b87caa4f6d2bcf57c2fa2b"}, - {file = "aiohttp-3.11.10-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8b3cf2dc0f0690a33f2d2b2cb15db87a65f1c609f53c37e226f84edb08d10f52"}, - {file = "aiohttp-3.11.10-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fbbaea811a2bba171197b08eea288b9402faa2bab2ba0858eecdd0a4105753a3"}, - {file = "aiohttp-3.11.10-cp311-cp311-win32.whl", hash = "sha256:4b2c7ac59c5698a7a8207ba72d9e9c15b0fc484a560be0788b31312c2c5504e4"}, - {file = "aiohttp-3.11.10-cp311-cp311-win_amd64.whl", hash = "sha256:974d3a2cce5fcfa32f06b13ccc8f20c6ad9c51802bb7f829eae8a1845c4019ec"}, - {file = "aiohttp-3.11.10-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b78f053a7ecfc35f0451d961dacdc671f4bcbc2f58241a7c820e9d82559844cf"}, - {file = "aiohttp-3.11.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab7485222db0959a87fbe8125e233b5a6f01f4400785b36e8a7878170d8c3138"}, - {file = "aiohttp-3.11.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cf14627232dfa8730453752e9cdc210966490992234d77ff90bc8dc0dce361d5"}, - {file = "aiohttp-3.11.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:076bc454a7e6fd646bc82ea7f98296be0b1219b5e3ef8a488afbdd8e81fbac50"}, - {file = "aiohttp-3.11.10-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:482cafb7dc886bebeb6c9ba7925e03591a62ab34298ee70d3dd47ba966370d2c"}, - {file = "aiohttp-3.11.10-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf3d1a519a324af764a46da4115bdbd566b3c73fb793ffb97f9111dbc684fc4d"}, - {file = "aiohttp-3.11.10-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24213ba85a419103e641e55c27dc7ff03536c4873470c2478cce3311ba1eee7b"}, - {file = "aiohttp-3.11.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b99acd4730ad1b196bfb03ee0803e4adac371ae8efa7e1cbc820200fc5ded109"}, - {file = "aiohttp-3.11.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:14cdb5a9570be5a04eec2ace174a48ae85833c2aadc86de68f55541f66ce42ab"}, - {file = "aiohttp-3.11.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:7e97d622cb083e86f18317282084bc9fbf261801b0192c34fe4b1febd9f7ae69"}, - {file = "aiohttp-3.11.10-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:012f176945af138abc10c4a48743327a92b4ca9adc7a0e078077cdb5dbab7be0"}, - {file = "aiohttp-3.11.10-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44224d815853962f48fe124748227773acd9686eba6dc102578defd6fc99e8d9"}, - {file = "aiohttp-3.11.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c87bf31b7fdab94ae3adbe4a48e711bfc5f89d21cf4c197e75561def39e223bc"}, - {file = "aiohttp-3.11.10-cp312-cp312-win32.whl", hash = "sha256:06a8e2ee1cbac16fe61e51e0b0c269400e781b13bcfc33f5425912391a542985"}, - {file = "aiohttp-3.11.10-cp312-cp312-win_amd64.whl", hash = "sha256:be2b516f56ea883a3e14dda17059716593526e10fb6303189aaf5503937db408"}, - {file = "aiohttp-3.11.10-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8cc5203b817b748adccb07f36390feb730b1bc5f56683445bfe924fc270b8816"}, - {file = "aiohttp-3.11.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ef359ebc6949e3a34c65ce20230fae70920714367c63afd80ea0c2702902ccf"}, - {file = "aiohttp-3.11.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9bca390cb247dbfaec3c664326e034ef23882c3f3bfa5fbf0b56cad0320aaca5"}, - {file = "aiohttp-3.11.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:811f23b3351ca532af598405db1093f018edf81368e689d1b508c57dcc6b6a32"}, - {file = "aiohttp-3.11.10-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ddf5f7d877615f6a1e75971bfa5ac88609af3b74796ff3e06879e8422729fd01"}, - {file = "aiohttp-3.11.10-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ab29b8a0beb6f8eaf1e5049252cfe74adbaafd39ba91e10f18caeb0e99ffb34"}, - {file = "aiohttp-3.11.10-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c49a76c1038c2dd116fa443eba26bbb8e6c37e924e2513574856de3b6516be99"}, - {file = "aiohttp-3.11.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f3dc0e330575f5b134918976a645e79adf333c0a1439dcf6899a80776c9ab39"}, - {file = "aiohttp-3.11.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:efb15a17a12497685304b2d976cb4939e55137df7b09fa53f1b6a023f01fcb4e"}, - {file = "aiohttp-3.11.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:db1d0b28fcb7f1d35600150c3e4b490775251dea70f894bf15c678fdd84eda6a"}, - {file = "aiohttp-3.11.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:15fccaf62a4889527539ecb86834084ecf6e9ea70588efde86e8bc775e0e7542"}, - {file = "aiohttp-3.11.10-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:593c114a2221444f30749cc5e5f4012488f56bd14de2af44fe23e1e9894a9c60"}, - {file = "aiohttp-3.11.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7852bbcb4d0d2f0c4d583f40c3bc750ee033265d80598d0f9cb6f372baa6b836"}, - {file = "aiohttp-3.11.10-cp313-cp313-win32.whl", hash = "sha256:65e55ca7debae8faaffee0ebb4b47a51b4075f01e9b641c31e554fd376595c6c"}, - {file = "aiohttp-3.11.10-cp313-cp313-win_amd64.whl", hash = "sha256:beb39a6d60a709ae3fb3516a1581777e7e8b76933bb88c8f4420d875bb0267c6"}, - {file = "aiohttp-3.11.10-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:0580f2e12de2138f34debcd5d88894786453a76e98febaf3e8fe5db62d01c9bf"}, - {file = "aiohttp-3.11.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a55d2ad345684e7c3dd2c20d2f9572e9e1d5446d57200ff630e6ede7612e307f"}, - {file = "aiohttp-3.11.10-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:04814571cb72d65a6899db6099e377ed00710bf2e3eafd2985166f2918beaf59"}, - {file = "aiohttp-3.11.10-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e44a9a3c053b90c6f09b1bb4edd880959f5328cf63052503f892c41ea786d99f"}, - {file = "aiohttp-3.11.10-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:502a1464ccbc800b4b1995b302efaf426e8763fadf185e933c2931df7db9a199"}, - {file = "aiohttp-3.11.10-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:613e5169f8ae77b1933e42e418a95931fb4867b2991fc311430b15901ed67079"}, - {file = "aiohttp-3.11.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4cca22a61b7fe45da8fc73c3443150c3608750bbe27641fc7558ec5117b27fdf"}, - {file = "aiohttp-3.11.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:86a5dfcc39309470bd7b68c591d84056d195428d5d2e0b5ccadfbaf25b026ebc"}, - {file = "aiohttp-3.11.10-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:77ae58586930ee6b2b6f696c82cf8e78c8016ec4795c53e36718365f6959dc82"}, - {file = "aiohttp-3.11.10-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:78153314f26d5abef3239b4a9af20c229c6f3ecb97d4c1c01b22c4f87669820c"}, - {file = "aiohttp-3.11.10-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:98283b94cc0e11c73acaf1c9698dea80c830ca476492c0fe2622bd931f34b487"}, - {file = "aiohttp-3.11.10-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:53bf2097e05c2accc166c142a2090e4c6fd86581bde3fd9b2d3f9e93dda66ac1"}, - {file = "aiohttp-3.11.10-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c5532f0441fc09c119e1dca18fbc0687e64fbeb45aa4d6a87211ceaee50a74c4"}, - {file = "aiohttp-3.11.10-cp39-cp39-win32.whl", hash = "sha256:47ad15a65fb41c570cd0ad9a9ff8012489e68176e7207ec7b82a0940dddfd8be"}, - {file = "aiohttp-3.11.10-cp39-cp39-win_amd64.whl", hash = "sha256:c6b9e6d7e41656d78e37ce754813fa44b455c3d0d0dced2a047def7dc5570b74"}, - {file = "aiohttp-3.11.10.tar.gz", hash = "sha256:b1fc6b45010a8d0ff9e88f9f2418c6fd408c99c211257334aff41597ebece42e"}, -] - -[package.dependencies] -aiohappyeyeballs = ">=2.3.0" -aiosignal = ">=1.1.2" +groups = ["main"] +files = [ + {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:906d5075b5ba0dd1c66fcaaf60eb09926a9fef3ca92d912d2a0bbdbecf8b1248"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c875bf6fc2fd1a572aba0e02ef4e7a63694778c5646cdbda346ee24e630d30fb"}, + {file = "aiohttp-3.12.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fbb284d15c6a45fab030740049d03c0ecd60edad9cd23b211d7e11d3be8d56fd"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38e360381e02e1a05d36b223ecab7bc4a6e7b5ab15760022dc92589ee1d4238c"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aaf90137b5e5d84a53632ad95ebee5c9e3e7468f0aab92ba3f608adcb914fa95"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e532a25e4a0a2685fa295a31acf65e027fbe2bea7a4b02cdfbbba8a064577663"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:eab9762c4d1b08ae04a6c77474e6136da722e34fdc0e6d6eab5ee93ac29f35d1"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abe53c3812b2899889a7fca763cdfaeee725f5be68ea89905e4275476ffd7e61"}, + {file = "aiohttp-3.12.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5760909b7080aa2ec1d320baee90d03b21745573780a072b66ce633eb77a8656"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:02fcd3f69051467bbaa7f84d7ec3267478c7df18d68b2e28279116e29d18d4f3"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4dcd1172cd6794884c33e504d3da3c35648b8be9bfa946942d353b939d5f1288"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:224d0da41355b942b43ad08101b1b41ce633a654128ee07e36d75133443adcda"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e387668724f4d734e865c1776d841ed75b300ee61059aca0b05bce67061dcacc"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:dec9cde5b5a24171e0b0a4ca064b1414950904053fb77c707efd876a2da525d8"}, + {file = "aiohttp-3.12.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bbad68a2af4877cc103cd94af9160e45676fc6f0c14abb88e6e092b945c2c8e3"}, + {file = "aiohttp-3.12.14-cp310-cp310-win32.whl", hash = "sha256:ee580cb7c00bd857b3039ebca03c4448e84700dc1322f860cf7a500a6f62630c"}, + {file = "aiohttp-3.12.14-cp310-cp310-win_amd64.whl", hash = "sha256:cf4f05b8cea571e2ccc3ca744e35ead24992d90a72ca2cf7ab7a2efbac6716db"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4552ff7b18bcec18b60a90c6982049cdb9dac1dba48cf00b97934a06ce2e597"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8283f42181ff6ccbcf25acaae4e8ab2ff7e92b3ca4a4ced73b2c12d8cd971393"}, + {file = "aiohttp-3.12.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:040afa180ea514495aaff7ad34ec3d27826eaa5d19812730fe9e529b04bb2179"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b413c12f14c1149f0ffd890f4141a7471ba4b41234fe4fd4a0ff82b1dc299dbb"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1d6f607ce2e1a93315414e3d448b831238f1874b9968e1195b06efaa5c87e245"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:565e70d03e924333004ed101599902bba09ebb14843c8ea39d657f037115201b"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4699979560728b168d5ab63c668a093c9570af2c7a78ea24ca5212c6cdc2b641"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad5fdf6af93ec6c99bf800eba3af9a43d8bfd66dce920ac905c817ef4a712afe"}, + {file = "aiohttp-3.12.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ac76627c0b7ee0e80e871bde0d376a057916cb008a8f3ffc889570a838f5cc7"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:798204af1180885651b77bf03adc903743a86a39c7392c472891649610844635"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4f1205f97de92c37dd71cf2d5bcfb65fdaed3c255d246172cce729a8d849b4da"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:76ae6f1dd041f85065d9df77c6bc9c9703da9b5c018479d20262acc3df97d419"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a194ace7bc43ce765338ca2dfb5661489317db216ea7ea700b0332878b392cab"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:16260e8e03744a6fe3fcb05259eeab8e08342c4c33decf96a9dad9f1187275d0"}, + {file = "aiohttp-3.12.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c779e5ebbf0e2e15334ea404fcce54009dc069210164a244d2eac8352a44b28"}, + {file = "aiohttp-3.12.14-cp311-cp311-win32.whl", hash = "sha256:a289f50bf1bd5be227376c067927f78079a7bdeccf8daa6a9e65c38bae14324b"}, + {file = "aiohttp-3.12.14-cp311-cp311-win_amd64.whl", hash = "sha256:0b8a69acaf06b17e9c54151a6c956339cf46db4ff72b3ac28516d0f7068f4ced"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a0ecbb32fc3e69bc25efcda7d28d38e987d007096cbbeed04f14a6662d0eee22"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0400f0ca9bb3e0b02f6466421f253797f6384e9845820c8b05e976398ac1d81a"}, + {file = "aiohttp-3.12.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a56809fed4c8a830b5cae18454b7464e1529dbf66f71c4772e3cfa9cbec0a1ff"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27f2e373276e4755691a963e5d11756d093e346119f0627c2d6518208483fb6d"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ca39e433630e9a16281125ef57ece6817afd1d54c9f1bf32e901f38f16035869"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c748b3f8b14c77720132b2510a7d9907a03c20ba80f469e58d5dfd90c079a1c"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a568abe1b15ce69d4cc37e23020720423f0728e3cb1f9bcd3f53420ec3bfe7"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9888e60c2c54eaf56704b17feb558c7ed6b7439bca1e07d4818ab878f2083660"}, + {file = "aiohttp-3.12.14-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3006a1dc579b9156de01e7916d38c63dc1ea0679b14627a37edf6151bc530088"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aa8ec5c15ab80e5501a26719eb48a55f3c567da45c6ea5bb78c52c036b2655c7"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:39b94e50959aa07844c7fe2206b9f75d63cc3ad1c648aaa755aa257f6f2498a9"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:04c11907492f416dad9885d503fbfc5dcb6768d90cad8639a771922d584609d3"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:88167bd9ab69bb46cee91bd9761db6dfd45b6e76a0438c7e884c3f8160ff21eb"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:791504763f25e8f9f251e4688195e8b455f8820274320204f7eafc467e609425"}, + {file = "aiohttp-3.12.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2785b112346e435dd3a1a67f67713a3fe692d288542f1347ad255683f066d8e0"}, + {file = "aiohttp-3.12.14-cp312-cp312-win32.whl", hash = "sha256:15f5f4792c9c999a31d8decf444e79fcfd98497bf98e94284bf390a7bb8c1729"}, + {file = "aiohttp-3.12.14-cp312-cp312-win_amd64.whl", hash = "sha256:3b66e1a182879f579b105a80d5c4bd448b91a57e8933564bf41665064796a338"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:3143a7893d94dc82bc409f7308bc10d60285a3cd831a68faf1aa0836c5c3c767"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3d62ac3d506cef54b355bd34c2a7c230eb693880001dfcda0bf88b38f5d7af7e"}, + {file = "aiohttp-3.12.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48e43e075c6a438937c4de48ec30fa8ad8e6dfef122a038847456bfe7b947b63"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:077b4488411a9724cecc436cbc8c133e0d61e694995b8de51aaf351c7578949d"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d8c35632575653f297dcbc9546305b2c1133391089ab925a6a3706dfa775ccab"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b8ce87963f0035c6834b28f061df90cf525ff7c9b6283a8ac23acee6502afd4"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f0a2cf66e32a2563bb0766eb24eae7e9a269ac0dc48db0aae90b575dc9583026"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdea089caf6d5cde975084a884c72d901e36ef9c2fd972c9f51efbbc64e96fbd"}, + {file = "aiohttp-3.12.14-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7865f27db67d49e81d463da64a59365ebd6b826e0e4847aa111056dcb9dc88"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0ab5b38a6a39781d77713ad930cb5e7feea6f253de656a5f9f281a8f5931b086"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9b3b15acee5c17e8848d90a4ebc27853f37077ba6aec4d8cb4dbbea56d156933"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e4c972b0bdaac167c1e53e16a16101b17c6d0ed7eac178e653a07b9f7fad7151"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7442488b0039257a3bdbc55f7209587911f143fca11df9869578db6c26feeeb8"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f68d3067eecb64c5e9bab4a26aa11bd676f4c70eea9ef6536b0a4e490639add3"}, + {file = "aiohttp-3.12.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f88d3704c8b3d598a08ad17d06006cb1ca52a1182291f04979e305c8be6c9758"}, + {file = "aiohttp-3.12.14-cp313-cp313-win32.whl", hash = "sha256:a3c99ab19c7bf375c4ae3debd91ca5d394b98b6089a03231d4c580ef3c2ae4c5"}, + {file = "aiohttp-3.12.14-cp313-cp313-win_amd64.whl", hash = "sha256:3f8aad695e12edc9d571f878c62bedc91adf30c760c8632f09663e5f564f4baa"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:b8cc6b05e94d837bcd71c6531e2344e1ff0fb87abe4ad78a9261d67ef5d83eae"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d1dcb015ac6a3b8facd3677597edd5ff39d11d937456702f0bb2b762e390a21b"}, + {file = "aiohttp-3.12.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3779ed96105cd70ee5e85ca4f457adbce3d9ff33ec3d0ebcdf6c5727f26b21b3"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:717a0680729b4ebd7569c1dcd718c46b09b360745fd8eb12317abc74b14d14d0"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b5dd3a2ef7c7e968dbbac8f5574ebeac4d2b813b247e8cec28174a2ba3627170"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4710f77598c0092239bc12c1fcc278a444e16c7032d91babf5abbf7166463f7b"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f3e9f75ae842a6c22a195d4a127263dbf87cbab729829e0bd7857fb1672400b2"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f9c8d55d6802086edd188e3a7d85a77787e50d56ce3eb4757a3205fa4657922"}, + {file = "aiohttp-3.12.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79b29053ff3ad307880d94562cca80693c62062a098a5776ea8ef5ef4b28d140"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:23e1332fff36bebd3183db0c7a547a1da9d3b4091509f6d818e098855f2f27d3"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:a564188ce831fd110ea76bcc97085dd6c625b427db3f1dbb14ca4baa1447dcbc"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:a7a1b4302f70bb3ec40ca86de82def532c97a80db49cac6a6700af0de41af5ee"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:1b07ccef62950a2519f9bfc1e5b294de5dd84329f444ca0b329605ea787a3de5"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:938bd3ca6259e7e48b38d84f753d548bd863e0c222ed6ee6ace3fd6752768a84"}, + {file = "aiohttp-3.12.14-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8bc784302b6b9f163b54c4e93d7a6f09563bd01ff2b841b29ed3ac126e5040bf"}, + {file = "aiohttp-3.12.14-cp39-cp39-win32.whl", hash = "sha256:a3416f95961dd7d5393ecff99e3f41dc990fb72eda86c11f2a60308ac6dcd7a0"}, + {file = "aiohttp-3.12.14-cp39-cp39-win_amd64.whl", hash = "sha256:196858b8820d7f60578f8b47e5669b3195c21d8ab261e39b1d705346458f445f"}, + {file = "aiohttp-3.12.14.tar.gz", hash = "sha256:6e06e120e34d93100de448fd941522e11dafa78ef1a893c179901b7d66aa29f2"}, +] + +[package.dependencies] +aiohappyeyeballs = ">=2.5.0" +aiosignal = ">=1.4.0" async-timeout = {version = ">=4.0,<6.0", markers = "python_version < \"3.11\""} attrs = ">=17.3.0" frozenlist = ">=1.1.1" @@ -123,7 +175,7 @@ propcache = ">=0.2.0" yarl = ">=1.17.0,<2.0" [package.extras] -speedups = ["Brotli", "aiodns (>=3.2.0)", "brotlicffi"] +speedups = ["Brotli ; platform_python_implementation == \"CPython\"", "aiodns (>=3.3.0)", "brotlicffi ; platform_python_implementation != \"CPython\""] [[package]] name = "aiormq" @@ -131,6 +183,7 @@ version = "6.8.1" description = "Pure python AMQP asynchronous client library" optional = false python-versions = "<4.0,>=3.8" +groups = ["main"] files = [ {file = "aiormq-6.8.1-py3-none-any.whl", hash = "sha256:5da896c8624193708f9409ffad0b20395010e2747f22aa4150593837f40aa017"}, {file = "aiormq-6.8.1.tar.gz", hash = "sha256:a964ab09634be1da1f9298ce225b310859763d5cf83ef3a7eae1a6dc6bd1da1a"}, @@ -142,17 +195,19 @@ yarl = "*" [[package]] name = "aiosignal" -version = "1.3.2" +version = "1.4.0" description = "aiosignal: a list of registered asynchronous callbacks" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "aiosignal-1.3.2-py2.py3-none-any.whl", hash = "sha256:45cde58e409a301715980c2b01d0c28bdde3770d8290b5eb2173759d9acb31a5"}, - {file = "aiosignal-1.3.2.tar.gz", hash = "sha256:a8c255c66fafb1e499c9351d0bf32ff2d8a0321595ebac3b93713656d2436f54"}, + {file = "aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e"}, + {file = "aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7"}, ] [package.dependencies] frozenlist = ">=1.1.0" +typing-extensions = {version = ">=4.2", markers = "python_version < \"3.13\""} [[package]] name = "annotated-types" @@ -160,6 +215,7 @@ version = "0.7.0" description = "Reusable constraint types to use with typing.Annotated" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, @@ -167,37 +223,40 @@ files = [ [[package]] name = "anthropic" -version = "0.40.0" +version = "0.59.0" description = "The official Python library for the anthropic API" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "anthropic-0.40.0-py3-none-any.whl", hash = "sha256:442028ae8790ff9e3b6f8912043918755af1230d193904ae2ef78cc22995280c"}, - {file = "anthropic-0.40.0.tar.gz", hash = "sha256:3efeca6d9e97813f93ed34322c6c7ea2279bf0824cd0aa71b59ce222665e2b87"}, + {file = "anthropic-0.59.0-py3-none-any.whl", hash = "sha256:cbc8b3dccef66ad6435c4fa1d317e5ebb092399a4b88b33a09dc4bf3944c3183"}, + {file = "anthropic-0.59.0.tar.gz", hash = "sha256:d710d1ef0547ebbb64b03f219e44ba078e83fc83752b96a9b22e9726b523fd8f"}, ] [package.dependencies] anyio = ">=3.5.0,<5" distro = ">=1.7.0,<2" -httpx = ">=0.23.0,<1" +httpx = ">=0.25.0,<1" jiter = ">=0.4.0,<1" pydantic = ">=1.9.0,<3" sniffio = "*" -typing-extensions = ">=4.7,<5" +typing-extensions = ">=4.10,<5" [package.extras] +aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"] bedrock = ["boto3 (>=1.28.57)", "botocore (>=1.31.57)"] -vertex = ["google-auth (>=2,<3)"] +vertex = ["google-auth[requests] (>=2,<3)"] [[package]] name = "anyio" -version = "4.7.0" +version = "4.9.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "anyio-4.7.0-py3-none-any.whl", hash = "sha256:ea60c3723ab42ba6fff7e8ccb0488c898ec538ff4df1f1d5e642c3601d07e352"}, - {file = "anyio-4.7.0.tar.gz", hash = "sha256:2f834749c602966b7d456a7567cafcb309f96482b5081d14ac93ccd457f9dd48"}, + {file = "anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c"}, + {file = "anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028"}, ] [package.dependencies] @@ -207,8 +266,8 @@ sniffio = ">=1.1" typing_extensions = {version = ">=4.5", markers = "python_version < \"3.13\""} [package.extras] -doc = ["Sphinx (>=7.4,<8.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] -test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "truststore (>=0.9.1)", "uvloop (>=0.21)"] +doc = ["Sphinx (>=8.2,<9.0)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx_rtd_theme"] +test = ["anyio[trio]", "blockbuster (>=1.5.23)", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "trustme", "truststore (>=0.9.1) ; python_version >= \"3.10\"", "uvloop (>=0.21) ; platform_python_implementation == \"CPython\" and platform_system != \"Windows\" and python_version < \"3.14\""] trio = ["trio (>=0.26.1)"] [[package]] @@ -217,6 +276,7 @@ version = "3.11.0" description = "In-process task scheduler with Cron-like capabilities" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da"}, {file = "apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133"}, @@ -233,7 +293,7 @@ mongodb = ["pymongo (>=3.0)"] redis = ["redis (>=3.0)"] rethinkdb = ["rethinkdb (>=2.4.0)"] sqlalchemy = ["sqlalchemy (>=1.4)"] -test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6", "anyio (>=4.5.2)", "gevent", "pytest", "pytz", "twisted"] +test = ["APScheduler[etcd,mongodb,redis,rethinkdb,sqlalchemy,tornado,zookeeper]", "PySide6 ; platform_python_implementation == \"CPython\" and python_version < \"3.14\"", "anyio (>=4.5.2)", "gevent ; python_version < \"3.14\"", "pytest", "pytz", "twisted ; python_version < \"3.14\""] tornado = ["tornado (>=4.3)"] twisted = ["twisted"] zookeeper = ["kazoo"] @@ -244,6 +304,8 @@ version = "5.0.1" description = "Timeout context manager for asyncio programs" optional = false python-versions = ">=3.8" +groups = ["main"] +markers = "python_full_version < \"3.11.3\"" files = [ {file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"}, {file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"}, @@ -251,54 +313,162 @@ files = [ [[package]] name = "attrs" -version = "24.3.0" +version = "25.3.0" description = "Classes Without Boilerplate" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "attrs-24.3.0-py3-none-any.whl", hash = "sha256:ac96cd038792094f438ad1f6ff80837353805ac950cd2aa0e0625ef19850c308"}, - {file = "attrs-24.3.0.tar.gz", hash = "sha256:8f5c07333d543103541ba7be0e2ce16eeee8130cb0b3f9238ab904ce1e85baff"}, + {file = "attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3"}, + {file = "attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b"}, ] [package.extras] -benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] -tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] -tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] +benchmark = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +cov = ["cloudpickle ; platform_python_implementation == \"CPython\"", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +dev = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] +tests = ["cloudpickle ; platform_python_implementation == \"CPython\"", "hypothesis", "mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1) ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\"", "pytest-mypy-plugins ; platform_python_implementation == \"CPython\" and python_version >= \"3.10\""] + +[[package]] +name = "audioop-lts" +version = "0.2.2" +description = "LTS Port of Python audioop" +optional = false +python-versions = ">=3.13" +groups = ["main"] +markers = "python_version >= \"3.13\"" +files = [ + {file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800"}, + {file = "audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303"}, + {file = "audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc"}, + {file = "audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7"}, + {file = "audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449"}, + {file = "audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636"}, + {file = "audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e"}, + {file = "audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547"}, + {file = "audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b"}, + {file = "audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd"}, + {file = "audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0"}, +] [[package]] name = "autogpt-libs" version = "0.2.0" -description = "Shared libraries across NextGen AutoGPT" +description = "Shared libraries across AutoGPT Platform" optional = false python-versions = ">=3.10,<4.0" +groups = ["main"] files = [] develop = true [package.dependencies] colorama = "^0.4.6" expiringdict = "^1.2.2" -google-cloud-logging = "^3.11.3" -pydantic = "^2.10.3" -pydantic-settings = "^2.7.0" +fastapi = "^0.116.1" +google-cloud-logging = "^3.12.1" +launchdarkly-server-sdk = "^9.12.0" +pydantic = "^2.11.7" +pydantic-settings = "^2.10.1" pyjwt = "^2.10.1" -pytest-asyncio = "^0.25.0" -pytest-mock = "^3.14.0" -python-dotenv = "^1.0.1" -supabase = "^2.10.0" +pytest-asyncio = "^1.1.0" +pytest-mock = "^3.14.1" +redis = "^6.2.0" +supabase = "^2.16.0" +uvicorn = "^0.35.0" [package.source] type = "directory" url = "../autogpt_libs" +[[package]] +name = "backoff" +version = "2.2.1" +description = "Function decoration for backoff and retry" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8"}, + {file = "backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba"}, +] + +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +description = "Backport of asyncio.Runner, a context manager that controls event loop life cycle." +optional = false +python-versions = "<3.11,>=3.8" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5"}, + {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +description = "Backport of CPython tarfile module" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version <= \"3.11\"" +files = [ + {file = "backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34"}, + {file = "backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991"}, +] + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["jaraco.test", "pytest (!=8.0.*)", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)"] + [[package]] name = "black" version = "24.10.0" description = "The uncompromising code formatter." optional = false python-versions = ">=3.9" +groups = ["main", "dev"] files = [ {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, @@ -339,26 +509,115 @@ d = ["aiohttp (>=3.10)"] jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] uvloop = ["uvloop (>=0.15.2)"] +[[package]] +name = "bleach" +version = "6.2.0" +description = "An easy safelist-based HTML-sanitizing tool." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e"}, + {file = "bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f"}, +] + +[package.dependencies] +tinycss2 = {version = ">=1.1.0,<1.5", optional = true, markers = "extra == \"css\""} +webencodings = "*" + +[package.extras] +css = ["tinycss2 (>=1.1.0,<1.5)"] + +[[package]] +name = "browserbase" +version = "1.4.0" +description = "The official Python library for the Browserbase API" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "browserbase-1.4.0-py3-none-any.whl", hash = "sha256:ea9f1fb4a88921975b8b9606835c441a59d8ce82ce00313a6d48bbe8e30f79fb"}, + {file = "browserbase-1.4.0.tar.gz", hash = "sha256:e2ed36f513c8630b94b826042c4bb9f497c333f3bd28e5b76cb708c65b4318a0"}, +] + +[package.dependencies] +anyio = ">=3.5.0,<5" +distro = ">=1.7.0,<2" +httpx = ">=0.23.0,<1" +pydantic = ">=1.9.0,<3" +sniffio = "*" +typing-extensions = ">=4.10,<5" + +[[package]] +name = "build" +version = "1.2.2.post1" +description = "A simple, correct Python build frontend" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "build-1.2.2.post1-py3-none-any.whl", hash = "sha256:1d61c0887fa860c01971625baae8bdd338e517b836a2f70dd1f7aa3a6b2fc5b5"}, + {file = "build-1.2.2.post1.tar.gz", hash = "sha256:b36993e92ca9375a219c99e606a122ff365a760a2d4bba0caa09bd5278b608b7"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "os_name == \"nt\""} +importlib-metadata = {version = ">=4.6", markers = "python_full_version < \"3.10.2\""} +packaging = ">=19.1" +pyproject_hooks = "*" +tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2023.08.17)", "sphinx (>=7.0,<8.0)", "sphinx-argparse-cli (>=1.5)", "sphinx-autodoc-typehints (>=1.10)", "sphinx-issues (>=3.0.0)"] +test = ["build[uv,virtualenv]", "filelock (>=3)", "pytest (>=6.2.4)", "pytest-cov (>=2.12)", "pytest-mock (>=2)", "pytest-rerunfailures (>=9.1)", "pytest-xdist (>=1.34)", "setuptools (>=42.0.0) ; python_version < \"3.10\"", "setuptools (>=56.0.0) ; python_version == \"3.10\"", "setuptools (>=56.0.0) ; python_version == \"3.11\"", "setuptools (>=67.8.0) ; python_version >= \"3.12\"", "wheel (>=0.36.0)"] +typing = ["build[uv]", "importlib-metadata (>=5.1)", "mypy (>=1.9.0,<1.10.0)", "tomli", "typing-extensions (>=3.7.4.3)"] +uv = ["uv (>=0.1.18)"] +virtualenv = ["virtualenv (>=20.0.35)"] + +[[package]] +name = "cachecontrol" +version = "0.14.3" +description = "httplib2 caching for requests" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "cachecontrol-0.14.3-py3-none-any.whl", hash = "sha256:b35e44a3113f17d2a31c1e6b27b9de6d4405f84ae51baa8c1d3cc5b633010cae"}, + {file = "cachecontrol-0.14.3.tar.gz", hash = "sha256:73e7efec4b06b20d9267b441c1f733664f989fb8688391b670ca812d70795d11"}, +] + +[package.dependencies] +filelock = {version = ">=3.8.0", optional = true, markers = "extra == \"filecache\""} +msgpack = ">=0.5.2,<2.0.0" +requests = ">=2.16.0" + +[package.extras] +dev = ["CacheControl[filecache,redis]", "build", "cherrypy", "codespell[tomli]", "furo", "mypy", "pytest", "pytest-cov", "ruff", "sphinx", "sphinx-copybutton", "tox", "types-redis", "types-requests"] +filecache = ["filelock (>=3.8.0)"] +redis = ["redis (>=2.10.5)"] + [[package]] name = "cachetools" -version = "5.5.0" +version = "5.5.2" description = "Extensible memoizing collections and decorators" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292"}, - {file = "cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a"}, + {file = "cachetools-5.5.2-py3-none-any.whl", hash = "sha256:d26a22bcc62eb95c3beabd9f1ee5e820d3d2704fe2967cbe350e20c8ffcd3f0a"}, + {file = "cachetools-5.5.2.tar.gz", hash = "sha256:1a661caa9175d26759571b2e19580f9d6393969e5dfca11fdb1f947a23e640d4"}, ] [[package]] name = "certifi" -version = "2024.12.14" +version = "2025.7.14" description = "Python package for providing Mozilla's CA Bundle." optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +groups = ["main", "dev"] files = [ - {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, - {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, + {file = "certifi-2025.7.14-py3-none-any.whl", hash = "sha256:6b31f564a415d79ee77df69d757bb49a5bb53bd9f756cbbe24394ffd6fc1f4b2"}, + {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, ] [[package]] @@ -367,6 +626,7 @@ version = "1.17.1" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14"}, {file = "cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67"}, @@ -441,128 +701,157 @@ files = [ pycparser = "*" [[package]] -name = "charset-normalizer" +name = "cfgv" version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + +[[package]] +name = "chardet" +version = "5.2.0" +description = "Universal encoding detector for Python 3" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "chardet-5.2.0-py3-none-any.whl", hash = "sha256:e1cf59446890a00105fe7b7912492ea04b6e6f06d4b742b2c788469e34c82970"}, + {file = "chardet-5.2.0.tar.gz", hash = "sha256:1b3b6ff479a8c414bc3fa2c0852995695c4a026dcd6d0633b2dd092ca39c1cf7"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, - {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, - {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, +python-versions = ">=3.7" +groups = ["main", "dev"] +files = [ + {file = "charset_normalizer-3.4.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7c48ed483eb946e6c04ccbe02c6b4d1d48e51944b6db70f697e089c193404941"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2d318c11350e10662026ad0eb71bb51c7812fc8590825304ae0bdd4ac283acd"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cbfacf36cb0ec2897ce0ebc5d08ca44213af24265bd56eca54bee7923c48fd6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:18dd2e350387c87dabe711b86f83c9c78af772c748904d372ade190b5c7c9d4d"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8075c35cd58273fee266c58c0c9b670947c19df5fb98e7b66710e04ad4e9ff86"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5bf4545e3b962767e5c06fe1738f951f77d27967cb2caa64c28be7c4563e162c"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7a6ab32f7210554a96cd9e33abe3ddd86732beeafc7a28e9955cdf22ffadbab0"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b33de11b92e9f75a2b545d6e9b6f37e398d86c3e9e9653c4864eb7e89c5773ef"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8755483f3c00d6c9a77f490c17e6ab0c8729e39e6390328e42521ef175380ae6"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:68a328e5f55ec37c57f19ebb1fdc56a248db2e3e9ad769919a58672958e8f366"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:21b2899062867b0e1fde9b724f8aecb1af14f2778d69aacd1a5a1853a597a5db"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win32.whl", hash = "sha256:e8082b26888e2f8b36a042a58307d5b917ef2b1cacab921ad3323ef91901c71a"}, + {file = "charset_normalizer-3.4.2-cp310-cp310-win_amd64.whl", hash = "sha256:f69a27e45c43520f5487f27627059b64aaf160415589230992cec34c5e18a509"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:be1e352acbe3c78727a16a455126d9ff83ea2dfdcbc83148d2982305a04714c2"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa88ca0b1932e93f2d961bf3addbb2db902198dca337d88c89e1559e066e7645"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d524ba3f1581b35c03cb42beebab4a13e6cdad7b36246bd22541fa585a56cccd"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28a1005facc94196e1fb3e82a3d442a9d9110b8434fc1ded7a24a2983c9888d8"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdb20a30fe1175ecabed17cbf7812f7b804b8a315a25f24678bcdf120a90077f"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f5d9ed7f254402c9e7d35d2f5972c9bbea9040e99cd2861bd77dc68263277c7"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:efd387a49825780ff861998cd959767800d54f8308936b21025326de4b5a42b9"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f0aa37f3c979cf2546b73e8222bbfa3dc07a641585340179d768068e3455e544"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e70e990b2137b29dc5564715de1e12701815dacc1d056308e2b17e9095372a82"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0c8c57f84ccfc871a48a47321cfa49ae1df56cd1d965a09abe84066f6853b9c0"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6b66f92b17849b85cad91259efc341dce9c1af48e2173bf38a85c6329f1033e5"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win32.whl", hash = "sha256:daac4765328a919a805fa5e2720f3e94767abd632ae410a9062dff5412bae65a"}, + {file = "charset_normalizer-3.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53efc7c7cee4c1e70661e2e112ca46a575f90ed9ae3fef200f2a25e954f4b28"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c"}, + {file = "charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7"}, + {file = "charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1cad5f45b3146325bb38d6855642f6fd609c3f7cad4dbaf75549bf3b904d3184"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2680962a4848b3c4f155dc2ee64505a9c57186d0d56b43123b17ca3de18f0fa"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:36b31da18b8890a76ec181c3cf44326bf2c48e36d393ca1b72b3f484113ea344"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4074c5a429281bf056ddd4c5d3b740ebca4d43ffffe2ef4bf4d2d05114299da"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9e36a97bee9b86ef9a1cf7bb96747eb7a15c2f22bdb5b516434b00f2a599f02"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:1b1bde144d98e446b056ef98e59c256e9294f6b74d7af6846bf5ffdafd687a7d"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:915f3849a011c1f593ab99092f3cecfcb4d65d8feb4a64cf1bf2d22074dc0ec4"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:fb707f3e15060adf5b7ada797624a6c6e0138e2a26baa089df64c68ee98e040f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:25a23ea5c7edc53e0f29bae2c44fcb5a1aa10591aae107f2a2b2583a9c5cbc64"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:770cab594ecf99ae64c236bc9ee3439c3f46be49796e265ce0cc8bc17b10294f"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win32.whl", hash = "sha256:6a0289e4589e8bdfef02a80478f1dfcb14f0ab696b5a00e1f4b8a14a307a3c58"}, + {file = "charset_normalizer-3.4.2-cp37-cp37m-win_amd64.whl", hash = "sha256:6fc1f5b51fa4cecaa18f2bd7a003f3dd039dd615cd69a2afd6d3b19aed6775f2"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:76af085e67e56c8816c3ccf256ebd136def2ed9654525348cfa744b6802b69eb"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e45ba65510e2647721e35323d6ef54c7974959f6081b58d4ef5d87c60c84919a"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:046595208aae0120559a67693ecc65dd75d46f7bf687f159127046628178dc45"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:75d10d37a47afee94919c4fab4c22b9bc2a8bf7d4f46f87363bcf0573f3ff4f5"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6333b3aa5a12c26b2a4d4e7335a28f1475e0e5e17d69d55141ee3cab736f66d1"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e8323a9b031aa0393768b87f04b4164a40037fb2a3c11ac06a03ffecd3618027"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:24498ba8ed6c2e0b56d4acbf83f2d989720a93b41d712ebd4f4979660db4417b"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:844da2b5728b5ce0e32d863af26f32b5ce61bc4273a9c720a9f3aa9df73b1455"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:65c981bdbd3f57670af8b59777cbfae75364b483fa8a9f420f08094531d54a01"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:3c21d4fca343c805a52c0c78edc01e3477f6dd1ad7c47653241cf2a206d4fc58"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dc7039885fa1baf9be153a0626e337aa7ec8bf96b0128605fb0d77788ddc1681"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win32.whl", hash = "sha256:8272b73e1c5603666618805fe821edba66892e2870058c94c53147602eab29c7"}, + {file = "charset_normalizer-3.4.2-cp38-cp38-win_amd64.whl", hash = "sha256:70f7172939fdf8790425ba31915bfbe8335030f05b9913d7ae00a87d4395620a"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:005fa3432484527f9732ebd315da8da8001593e2cf46a3d817669f062c3d9ed4"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e92fca20c46e9f5e1bb485887d074918b13543b1c2a1185e69bb8d17ab6236a7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50bf98d5e563b83cc29471fa114366e6806bc06bc7a25fd59641e41445327836"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:721c76e84fe669be19c5791da68232ca2e05ba5185575086e384352e2c309597"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d8fd25b7f4675d0c47cf95b594d4e7b158aca33b76aa63d07186e13c0e0ab7"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3daeac64d5b371dea99714f08ffc2c208522ec6b06fbc7866a450dd446f5c0f"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dccab8d5fa1ef9bfba0590ecf4d46df048d18ffe3eec01eeb73a42e0d9e7a8ba"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:aaf27faa992bfee0264dc1f03f4c75e9fcdda66a519db6b957a3f826e285cf12"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:eb30abc20df9ab0814b5a2524f23d75dcf83cde762c161917a2b4b7b55b1e518"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c72fbbe68c6f32f251bdc08b8611c7b3060612236e960ef848e0a517ddbe76c5"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:982bb1e8b4ffda883b3d0a521e23abcd6fd17418f6d2c4118d257a10199c0ce3"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win32.whl", hash = "sha256:43e0933a0eff183ee85833f341ec567c0980dae57c464d8a508e1b2ceb336471"}, + {file = "charset_normalizer-3.4.2-cp39-cp39-win_amd64.whl", hash = "sha256:d11b54acf878eef558599658b0ffca78138c8c3655cf4f3a4a673c437e67732e"}, + {file = "charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0"}, + {file = "charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63"}, +] + +[[package]] +name = "cleo" +version = "2.1.0" +description = "Cleo allows you to create beautiful and testable command-line interfaces." +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "cleo-2.1.0-py3-none-any.whl", hash = "sha256:4a31bd4dd45695a64ee3c4758f583f134267c2bc518d8ae9a29cf237d009b07e"}, + {file = "cleo-2.1.0.tar.gz", hash = "sha256:0b2c880b5d13660a7ea651001fb4acb527696c01f15c9ee650f377aa543fd523"}, ] +[package.dependencies] +crashtest = ">=0.4.1,<0.5.0" +rapidfuzz = ">=3.0.0,<4.0.0" + [[package]] name = "click" -version = "8.1.7" +version = "8.2.1" description = "Composable command line interface toolkit" optional = false -python-versions = ">=3.7" +python-versions = ">=3.10" +groups = ["main", "dev"] files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, + {file = "click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b"}, + {file = "click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202"}, ] [package.dependencies] @@ -574,20 +863,35 @@ version = "0.4.6" description = "Cross-platform colored terminal text." optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +markers = {dev = "sys_platform == \"win32\" or platform_system == \"Windows\""} + +[[package]] +name = "crashtest" +version = "0.4.1" +description = "Manage Python errors with ease" +optional = false +python-versions = ">=3.7,<4.0" +groups = ["main"] +files = [ + {file = "crashtest-0.4.1-py3-none-any.whl", hash = "sha256:8d23eac5fa660409f57472e3851dab7ac18aba459a8d19cbbba86d3d5aecd2a5"}, + {file = "crashtest-0.4.1.tar.gz", hash = "sha256:80d7b1f316ebfbd429f648076d6275c877ba30ba48979de4191714a75266f0ce"}, +] [[package]] name = "croniter" -version = "5.0.1" +version = "6.0.0" description = "croniter provides iteration for datetime object with cron like format" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.6" +groups = ["main"] files = [ - {file = "croniter-5.0.1-py2.py3-none-any.whl", hash = "sha256:eb28439742291f6c10b181df1a5ecf421208b1fc62ef44501daec1780a0b09e9"}, - {file = "croniter-5.0.1.tar.gz", hash = "sha256:7d9b1ef25b10eece48fdf29d8ac52f9b6252abff983ac614ade4f3276294019e"}, + {file = "croniter-6.0.0-py2.py3-none-any.whl", hash = "sha256:2f878c3856f17896979b2a4379ba1f09c83e374931ea15cc835c5dd2eee9b368"}, + {file = "croniter-6.0.0.tar.gz", hash = "sha256:37c504b313956114a983ece2c2b07790b1f1094fe9d81cc94739214748255577"}, ] [package.dependencies] @@ -600,6 +904,7 @@ version = "43.0.3" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "cryptography-43.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf7a1932ac4176486eab36a19ed4c0492da5d97123f1406cf15e41b05e787d2e"}, {file = "cryptography-43.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63efa177ff54aec6e1c0aefaa1a241232dcd37413835a9b674b6e3f0ae2bfd3e"}, @@ -643,40 +948,37 @@ ssh = ["bcrypt (>=3.1.5)"] test = ["certifi", "cryptography-vectors (==43.0.3)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] +[[package]] +name = "decorator" +version = "5.2.1" +description = "Decorators for Humans" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a"}, + {file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"}, +] + [[package]] name = "defusedxml" version = "0.7.1" description = "XML bomb protection for Python stdlib modules" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +groups = ["main"] files = [ {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, ] -[[package]] -name = "deprecated" -version = "1.2.15" -description = "Python @deprecated decorator to deprecate old python classes, functions or methods." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" -files = [ - {file = "Deprecated-1.2.15-py2.py3-none-any.whl", hash = "sha256:353bc4a8ac4bfc96800ddab349d89c25dec1079f65fd53acdcc1e0b975b21320"}, - {file = "deprecated-1.2.15.tar.gz", hash = "sha256:683e561a90de76239796e6b6feac66b99030d2dd3fcf61ef996330f14bbb9b0d"}, -] - -[package.dependencies] -wrapt = ">=1.10,<2" - -[package.extras] -dev = ["PyTest", "PyTest-Cov", "bump2version (<1)", "jinja2 (>=3.0.3,<3.1.0)", "setuptools", "sphinx (<2)", "tox"] - [[package]] name = "deprecation" version = "2.1.0" description = "A library to handle automated deprecations" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a"}, {file = "deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff"}, @@ -687,44 +989,149 @@ packaging = "*" [[package]] name = "discord-py" -version = "2.4.0" +version = "2.5.2" description = "A Python wrapper for the Discord API" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "discord.py-2.4.0-py3-none-any.whl", hash = "sha256:b8af6711c70f7e62160bfbecb55be699b5cb69d007426759ab8ab06b1bd77d1d"}, - {file = "discord_py-2.4.0.tar.gz", hash = "sha256:d07cb2a223a185873a1d0ee78b9faa9597e45b3f6186df21a95cec1e9bcdc9a5"}, + {file = "discord_py-2.5.2-py3-none-any.whl", hash = "sha256:81f23a17c50509ffebe0668441cb80c139e74da5115305f70e27ce821361295a"}, + {file = "discord_py-2.5.2.tar.gz", hash = "sha256:01cd362023bfea1a4a1d43f5280b5ef00cad2c7eba80098909f98bf28e578524"}, ] [package.dependencies] aiohttp = ">=3.7.4,<4" +audioop-lts = {version = "*", markers = "python_version >= \"3.13\""} [package.extras] -docs = ["sphinx (==4.4.0)", "sphinx-inline-tabs (==2023.4.21)", "sphinxcontrib-applehelp (==1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (==2.0.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)", "sphinxcontrib-trio (==1.1.2)", "sphinxcontrib-websupport (==1.2.4)", "typing-extensions (>=4.3,<5)"] -speed = ["Brotli", "aiodns (>=1.1)", "cchardet (==2.1.7)", "orjson (>=3.5.4)"] -test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)", "tzdata"] +dev = ["black (==22.6)", "typing_extensions (>=4.3,<5)"] +docs = ["imghdr-lts (==1.0.0) ; python_version >= \"3.13\"", "sphinx (==4.4.0)", "sphinx-inline-tabs (==2023.4.21)", "sphinxcontrib-applehelp (==1.0.4)", "sphinxcontrib-devhelp (==1.0.2)", "sphinxcontrib-htmlhelp (==2.0.1)", "sphinxcontrib-jsmath (==1.0.1)", "sphinxcontrib-qthelp (==1.0.3)", "sphinxcontrib-serializinghtml (==1.1.5)", "sphinxcontrib-websupport (==1.2.4)", "sphinxcontrib_trio (==1.1.2)", "typing-extensions (>=4.3,<5)"] +speed = ["Brotli", "aiodns (>=1.1) ; sys_platform != \"win32\"", "cchardet (==2.1.7) ; python_version < \"3.10\"", "orjson (>=3.5.4)", "zstandard (>=0.23.0)"] +test = ["coverage[toml]", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "typing-extensions (>=4.3,<5)", "tzdata ; sys_platform == \"win32\""] voice = ["PyNaCl (>=1.3.0,<1.6)"] +[[package]] +name = "distlib" +version = "0.4.0" +description = "Distribution utilities" +optional = false +python-versions = "*" +groups = ["main", "dev"] +files = [ + {file = "distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16"}, + {file = "distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d"}, +] + [[package]] name = "distro" version = "1.9.0" description = "Distro - an OS platform information API" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2"}, {file = "distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed"}, ] +[[package]] +name = "dnspython" +version = "2.7.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, + {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, +] + +[package.extras] +dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] +dnssec = ["cryptography (>=43)"] +doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] +doq = ["aioquic (>=1.0.0)"] +idna = ["idna (>=3.7)"] +trio = ["trio (>=0.23)"] +wmi = ["wmi (>=1.5.1)"] + +[[package]] +name = "dulwich" +version = "0.22.8" +description = "Python Git Library" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "dulwich-0.22.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546176d18b8cc0a492b0f23f07411e38686024cffa7e9d097ae20512a2e57127"}, + {file = "dulwich-0.22.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d2434dd72b2ae09b653c9cfe6764a03c25cfbd99fbbb7c426f0478f6fb1100f"}, + {file = "dulwich-0.22.8-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8318bc0921d42e3e69f03716f983a301b5ee4c8dc23c7f2c5bbb28581257a9"}, + {file = "dulwich-0.22.8-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7a0f96a2a87f3b4f7feae79d2ac6b94107d6b7d827ac08f2f331b88c8f597a1"}, + {file = "dulwich-0.22.8-cp310-cp310-win32.whl", hash = "sha256:432a37b25733202897b8d67cdd641688444d980167c356ef4e4dd15a17a39a24"}, + {file = "dulwich-0.22.8-cp310-cp310-win_amd64.whl", hash = "sha256:f3a15e58dac8b8a76073ddca34e014f66f3672a5540a99d49ef6a9c09ab21285"}, + {file = "dulwich-0.22.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0852edc51cff4f4f62976bdaa1d82f6ef248356c681c764c0feb699bc17d5782"}, + {file = "dulwich-0.22.8-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:826aae8b64ac1a12321d6b272fc13934d8f62804fda2bc6ae46f93f4380798eb"}, + {file = "dulwich-0.22.8-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7ae726f923057d36cdbb9f4fb7da0d0903751435934648b13f1b851f0e38ea1"}, + {file = "dulwich-0.22.8-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6987d753227f55cf75ba29a8dab69d1d83308ce483d7a8c6d223086f7a42e125"}, + {file = "dulwich-0.22.8-cp311-cp311-win32.whl", hash = "sha256:7757b4a2aad64c6f1920082fc1fccf4da25c3923a0ae7b242c08d06861dae6e1"}, + {file = "dulwich-0.22.8-cp311-cp311-win_amd64.whl", hash = "sha256:12b243b7e912011c7225dc67480c313ac8d2990744789b876016fb593f6f3e19"}, + {file = "dulwich-0.22.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d81697f74f50f008bb221ab5045595f8a3b87c0de2c86aa55be42ba97421f3cd"}, + {file = "dulwich-0.22.8-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bff1da8e2e6a607c3cb45f5c2e652739589fe891245e1d5b770330cdecbde41"}, + {file = "dulwich-0.22.8-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9969099e15b939d3936f8bee8459eaef7ef5a86cd6173393a17fe28ca3d38aff"}, + {file = "dulwich-0.22.8-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:017152c51b9a613f0698db28c67cf3e0a89392d28050dbf4f4ac3f657ea4c0dc"}, + {file = "dulwich-0.22.8-cp312-cp312-win32.whl", hash = "sha256:ee70e8bb8798b503f81b53f7a103cb869c8e89141db9005909f79ab1506e26e9"}, + {file = "dulwich-0.22.8-cp312-cp312-win_amd64.whl", hash = "sha256:dc89c6f14dcdcbfee200b0557c59ae243835e42720be143526d834d0e53ed3af"}, + {file = "dulwich-0.22.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dbade3342376be1cd2409539fe1b901d2d57a531106bbae204da921ef4456a74"}, + {file = "dulwich-0.22.8-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:71420ffb6deebc59b2ce875e63d814509f9c1dc89c76db962d547aebf15670c7"}, + {file = "dulwich-0.22.8-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a626adbfac44646a125618266a24133763bdc992bf8bd0702910d67e6b994443"}, + {file = "dulwich-0.22.8-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f1476c9c4e4ede95714d06c4831883a26680e37b040b8b6230f506e5ba39f51"}, + {file = "dulwich-0.22.8-cp313-cp313-win32.whl", hash = "sha256:b2b31913932bb5bd41658dd398b33b1a2d4d34825123ad54e40912cfdfe60003"}, + {file = "dulwich-0.22.8-cp313-cp313-win_amd64.whl", hash = "sha256:7a44e5a61a7989aca1e301d39cfb62ad2f8853368682f524d6e878b4115d823d"}, + {file = "dulwich-0.22.8-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f9cd0c67fb44a38358b9fcabee948bf11044ef6ce7a129e50962f54c176d084e"}, + {file = "dulwich-0.22.8-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b79b94726c3f4a9e5a830c649376fd0963236e73142a4290bac6bc9fc9cb120"}, + {file = "dulwich-0.22.8-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16bbe483d663944972e22d64e1f191201123c3b5580fbdaac6a4f66bfaa4fc11"}, + {file = "dulwich-0.22.8-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e02d403af23d93dc1f96eb2408e25efd50046e38590a88c86fa4002adc9849b0"}, + {file = "dulwich-0.22.8-cp39-cp39-win32.whl", hash = "sha256:8bdd9543a77fb01be704377f5e634b71f955fec64caa4a493dc3bfb98e3a986e"}, + {file = "dulwich-0.22.8-cp39-cp39-win_amd64.whl", hash = "sha256:3b6757c6b3ba98212b854a766a4157b9cb79a06f4e1b06b46dec4bd834945b8e"}, + {file = "dulwich-0.22.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7bb18fa09daa1586c1040b3e2777d38d4212a5cdbe47d384ba66a1ac336fcc4c"}, + {file = "dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2b2fda8e87907ed304d4a5962aea0338366144df0df60f950b8f7f125871707f"}, + {file = "dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1748cd573a0aee4d530bc223a23ccb8bb5b319645931a37bd1cfb68933b720c1"}, + {file = "dulwich-0.22.8-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a631b2309feb9a9631eabd896612ba36532e3ffedccace57f183bb868d7afc06"}, + {file = "dulwich-0.22.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:00e7d9a3d324f9e0a1b27880eec0e8e276ff76519621b66c1a429ca9eb3f5a8d"}, + {file = "dulwich-0.22.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f8aa3de93201f9e3e40198725389aa9554a4ee3318a865f96a8e9bc9080f0b25"}, + {file = "dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e8da9dd8135884975f5be0563ede02179240250e11f11942801ae31ac293f37"}, + {file = "dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4fc5ce2435fb3abdf76f1acabe48f2e4b3f7428232cadaef9daaf50ea7fa30ee"}, + {file = "dulwich-0.22.8-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:982b21cc3100d959232cadb3da0a478bd549814dd937104ea50f43694ec27153"}, + {file = "dulwich-0.22.8-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6bde2b13a05cc0ec2ecd4597a99896663544c40af1466121f4d046119b874ce3"}, + {file = "dulwich-0.22.8-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6d446cb7d272a151934ad4b48ba691f32486d5267cf2de04ee3b5e05fc865326"}, + {file = "dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f6338e6cf95cd76a0191b3637dc3caed1f988ae84d8e75f876d5cd75a8dd81a"}, + {file = "dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e004fc532ea262f2d5f375068101ca4792becb9d4aa663b050f5ac31fda0bb5c"}, + {file = "dulwich-0.22.8-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6bfdbc6fa477dee00d04e22d43a51571cd820cfaaaa886f0f155b8e29b3e3d45"}, + {file = "dulwich-0.22.8-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:ae900c8e573f79d714c1d22b02cdadd50b64286dd7203028f0200f82089e4950"}, + {file = "dulwich-0.22.8-py3-none-any.whl", hash = "sha256:ffc7a02e62b72884de58baaa3b898b7f6427893e79b1289ffa075092efe59181"}, + {file = "dulwich-0.22.8.tar.gz", hash = "sha256:701547310415de300269331abe29cb5717aa1ea377af826bf513d0adfb1c209b"}, +] + +[package.dependencies] +urllib3 = ">=1.25" + +[package.extras] +dev = ["mypy (==1.15.0)", "ruff (==0.9.7)"] +fastimport = ["fastimport"] +https = ["urllib3 (>=1.24.1)"] +paramiko = ["paramiko"] +pgp = ["gpg"] + [[package]] name = "e2b" -version = "1.0.5" +version = "1.6.0" description = "E2B SDK that give agents cloud environments" optional = false -python-versions = "<4.0,>=3.8" +python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "e2b-1.0.5-py3-none-any.whl", hash = "sha256:a71bdec46f33d3e38e87d475d7fd2939bd7b6b753b819c9639ca211cd375b79e"}, - {file = "e2b-1.0.5.tar.gz", hash = "sha256:43c82705af7b7d4415c2510ff77dab4dc075351e0b769d6adf8e0d7bb4868d13"}, + {file = "e2b-1.6.0-py3-none-any.whl", hash = "sha256:c4606ed0a3d4e45412ce81e2e8ee224b2d81b927fd8d6519a1a81437d8d4a22a"}, + {file = "e2b-1.6.0.tar.gz", hash = "sha256:0552115ee0a18c4e5a5aa97290d4a454478c547371386cabb76e367b8e914753"}, ] [package.dependencies] @@ -732,36 +1139,77 @@ attrs = ">=23.2.0" httpcore = ">=1.0.5,<2.0.0" httpx = ">=0.27.0,<1.0.0" packaging = ">=24.1" -protobuf = ">=3.20.0,<6.0.0" +protobuf = ">=5.29.4,<6.0.0" python-dateutil = ">=2.8.2" typing-extensions = ">=4.1.0" [[package]] name = "e2b-code-interpreter" -version = "1.0.3" +version = "1.5.2" description = "E2B Code Interpreter - Stateful code execution" optional = false -python-versions = "<4.0,>=3.8" +python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "e2b_code_interpreter-1.0.3-py3-none-any.whl", hash = "sha256:c638bd4ec1c99d9c4eaac541bc8b15134cf786f6c7c400d979cef96d62e485d8"}, - {file = "e2b_code_interpreter-1.0.3.tar.gz", hash = "sha256:36475acc001b1317ed129d65970fce6a7cc2d50e3fd3e8a13ad5d7d3e0fac237"}, + {file = "e2b_code_interpreter-1.5.2-py3-none-any.whl", hash = "sha256:5c3188d8f25226b28fef4b255447cc6a4c36afb748bdd5180b45be486d5169f3"}, + {file = "e2b_code_interpreter-1.5.2.tar.gz", hash = "sha256:3bd6ea70596290e85aaf0a2f19f28bf37a5e73d13086f5e6a0080bb591c5a547"}, ] [package.dependencies] attrs = ">=21.3.0" -e2b = ">=1.0.4,<2.0.0" +e2b = ">=1.5.4,<2.0.0" httpx = ">=0.20.0,<1.0.0" +[[package]] +name = "email-validator" +version = "2.2.0" +description = "A robust email address syntax and deliverability validation library." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631"}, + {file = "email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7"}, +] + +[package.dependencies] +dnspython = ">=2.0.0" +idna = ">=2.0.0" + +[[package]] +name = "exa-py" +version = "1.14.20" +description = "Python SDK for Exa API." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "exa_py-1.14.20-py3-none-any.whl", hash = "sha256:e0ed9d99c3c494a0e6903e11a0f6fb773b3b23d0cd802380cf58efc97d9d332d"}, + {file = "exa_py-1.14.20.tar.gz", hash = "sha256:423789a0635b7a4ecd5f56d6b4a0dfb01126fa45ce1e04106c0bb96b7d551ebf"}, +] + +[package.dependencies] +httpx = ">=0.28.1" +openai = ">=1.48" +pydantic = ">=2.10.6" +requests = ">=2.32.3" +typing-extensions = ">=4.12.2" + [[package]] name = "exceptiongroup" -version = "1.2.2" +version = "1.3.0" description = "Backport of PEP 654 (exception groups)" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, + {file = "exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10"}, + {file = "exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88"}, ] +markers = {dev = "python_version < \"3.11\""} + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] @@ -772,6 +1220,7 @@ version = "1.2.2" description = "Dictionary with auto-expiring values for caching purposes" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "expiringdict-1.2.2-py3-none-any.whl", hash = "sha256:09a5d20bc361163e6432a874edd3179676e935eb81b925eccef48d409a8a45e8"}, {file = "expiringdict-1.2.2.tar.gz", hash = "sha256:300fb92a7e98f15b05cf9a856c1415b3bc4f2e132be07daa326da6414c23ee09"}, @@ -782,38 +1231,55 @@ tests = ["coverage", "coveralls", "dill", "mock", "nose"] [[package]] name = "faker" -version = "33.1.0" +version = "37.4.2" description = "Faker is a Python package that generates fake data for you." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "Faker-33.1.0-py3-none-any.whl", hash = "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d"}, - {file = "faker-33.1.0.tar.gz", hash = "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4"}, + {file = "faker-37.4.2-py3-none-any.whl", hash = "sha256:b70ed1af57bfe988cbcd0afd95f4768c51eaf4e1ce8a30962e127ac5c139c93f"}, + {file = "faker-37.4.2.tar.gz", hash = "sha256:8e281bbaea30e5658895b8bea21cc50d27aaf3a43db3f2694409ca5701c56b0a"}, ] [package.dependencies] -python-dateutil = ">=2.4" -typing-extensions = "*" +tzdata = "*" [[package]] name = "fastapi" -version = "0.115.6" +version = "0.116.1" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "fastapi-0.115.6-py3-none-any.whl", hash = "sha256:e9240b29e36fa8f4bb7290316988e90c381e5092e0cbe84e7818cc3713bcf305"}, - {file = "fastapi-0.115.6.tar.gz", hash = "sha256:9ec46f7addc14ea472958a96aae5b5de65f39721a46aaf5705c480d9a8b76654"}, + {file = "fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565"}, + {file = "fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143"}, ] [package.dependencies] pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.40.0,<0.42.0" +starlette = ">=0.40.0,<0.48.0" typing-extensions = ">=4.8.0" [package.extras] -all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] -standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "fastjsonschema" +version = "2.21.1" +description = "Fastest Python implementation of JSON schema" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667"}, + {file = "fastjsonschema-2.21.1.tar.gz", hash = "sha256:794d4f0a58f848961ba16af7b9c85a3e88cd360df008c59aac6fc5ae9323b5d4"}, +] + +[package.extras] +devel = ["colorama", "json-spec", "jsonschema", "pylint", "pytest", "pytest-benchmark", "pytest-cache", "validictory"] [[package]] name = "feedparser" @@ -821,6 +1287,7 @@ version = "6.0.11" description = "Universal feed parser, handles RSS 0.9x, RSS 1.0, RSS 2.0, CDF, Atom 0.3, and Atom 1.0 feeds" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "feedparser-6.0.11-py3-none-any.whl", hash = "sha256:0be7ee7b395572b19ebeb1d6aafb0028dee11169f1c934e0ed67d54992f4ad45"}, {file = "feedparser-6.0.11.tar.gz", hash = "sha256:c9d0407b64c6f2a065d0ebb292c2b35c01050cc0dc33757461aaabdc4c4184d5"}, @@ -829,182 +1296,331 @@ files = [ [package.dependencies] sgmllib3k = "*" +[[package]] +name = "filelock" +version = "3.18.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.9" +groups = ["main", "dev"] +files = [ + {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, + {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] + +[[package]] +name = "findpython" +version = "0.6.3" +description = "A utility to find python versions on your system" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "findpython-0.6.3-py3-none-any.whl", hash = "sha256:a85bb589b559cdf1b87227cc233736eb7cad894b9e68021ee498850611939ebc"}, + {file = "findpython-0.6.3.tar.gz", hash = "sha256:5863ea55556d8aadc693481a14ac4f3624952719efc1c5591abb0b4a9e965c94"}, +] + +[package.dependencies] +packaging = ">=20" + +[[package]] +name = "firecrawl-py" +version = "2.16.3" +description = "Python SDK for Firecrawl API" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "firecrawl_py-2.16.3-py3-none-any.whl", hash = "sha256:94bb46af5e0df6c8ec414ac999a5355c0f5a46f15fd1cf5a02a3b31062db0aa8"}, + {file = "firecrawl_py-2.16.3.tar.gz", hash = "sha256:5fd063ef4acc4c4be62648f1e11467336bc127780b3afc28d39078a012e6a14c"}, +] + +[package.dependencies] +aiohttp = "*" +nest-asyncio = "*" +pydantic = "*" +python-dotenv = "*" +requests = "*" +websockets = "*" + [[package]] name = "flake8" -version = "7.1.1" +version = "7.3.0" description = "the modular source code checker: pep8 pyflakes and co" optional = false -python-versions = ">=3.8.1" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "flake8-7.1.1-py2.py3-none-any.whl", hash = "sha256:597477df7860daa5aa0fdd84bf5208a043ab96b8e96ab708770ae0364dd03213"}, - {file = "flake8-7.1.1.tar.gz", hash = "sha256:049d058491e228e03e67b390f311bbf88fce2dbaa8fa673e7aea87b7198b8d38"}, + {file = "flake8-7.3.0-py2.py3-none-any.whl", hash = "sha256:b9696257b9ce8beb888cdbe31cf885c90d31928fe202be0889a7cdafad32f01e"}, + {file = "flake8-7.3.0.tar.gz", hash = "sha256:fe044858146b9fc69b551a4b490d69cf960fcb78ad1edcb84e7fbb1b4a8e3872"}, ] [package.dependencies] mccabe = ">=0.7.0,<0.8.0" -pycodestyle = ">=2.12.0,<2.13.0" -pyflakes = ">=3.2.0,<3.3.0" +pycodestyle = ">=2.14.0,<2.15.0" +pyflakes = ">=3.4.0,<3.5.0" [[package]] name = "frozenlist" -version = "1.5.0" +version = "1.7.0" description = "A list-like structure which implements collections.abc.MutableSequence" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cc4df77d638aa2ed703b878dd093725b72a824c3c546c076e8fdf276f78ee84a"}, + {file = "frozenlist-1.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:716a9973a2cc963160394f701964fe25012600f3d311f60c790400b00e568b61"}, + {file = "frozenlist-1.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a0fd1bad056a3600047fb9462cff4c5322cebc59ebf5d0a3725e0ee78955001d"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3789ebc19cb811163e70fe2bd354cea097254ce6e707ae42e56f45e31e96cb8e"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af369aa35ee34f132fcfad5be45fbfcde0e3a5f6a1ec0712857f286b7d20cca9"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac64b6478722eeb7a3313d494f8342ef3478dff539d17002f849101b212ef97c"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f89f65d85774f1797239693cef07ad4c97fdd0639544bad9ac4b869782eb1981"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1073557c941395fdfcfac13eb2456cb8aad89f9de27bae29fabca8e563b12615"}, + {file = "frozenlist-1.7.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed8d2fa095aae4bdc7fdd80351009a48d286635edffee66bf865e37a9125c50"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:24c34bea555fe42d9f928ba0a740c553088500377448febecaa82cc3e88aa1fa"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:69cac419ac6a6baad202c85aaf467b65ac860ac2e7f2ac1686dc40dbb52f6577"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:960d67d0611f4c87da7e2ae2eacf7ea81a5be967861e0c63cf205215afbfac59"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:41be2964bd4b15bf575e5daee5a5ce7ed3115320fb3c2b71fca05582ffa4dc9e"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:46d84d49e00c9429238a7ce02dc0be8f6d7cd0cd405abd1bebdc991bf27c15bd"}, + {file = "frozenlist-1.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15900082e886edb37480335d9d518cec978afc69ccbc30bd18610b7c1b22a718"}, + {file = "frozenlist-1.7.0-cp310-cp310-win32.whl", hash = "sha256:400ddd24ab4e55014bba442d917203c73b2846391dd42ca5e38ff52bb18c3c5e"}, + {file = "frozenlist-1.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:6eb93efb8101ef39d32d50bce242c84bcbddb4f7e9febfa7b524532a239b4464"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa51e147a66b2d74de1e6e2cf5921890de6b0f4820b257465101d7f37b49fb5a"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9b35db7ce1cd71d36ba24f80f0c9e7cff73a28d7a74e91fe83e23d27c7828750"}, + {file = "frozenlist-1.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34a69a85e34ff37791e94542065c8416c1afbf820b68f720452f636d5fb990cd"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4a646531fa8d82c87fe4bb2e596f23173caec9185bfbca5d583b4ccfb95183e2"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:79b2ffbba483f4ed36a0f236ccb85fbb16e670c9238313709638167670ba235f"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a26f205c9ca5829cbf82bb2a84b5c36f7184c4316617d7ef1b271a56720d6b30"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bcacfad3185a623fa11ea0e0634aac7b691aa925d50a440f39b458e41c561d98"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:72c1b0fe8fe451b34f12dce46445ddf14bd2a5bcad7e324987194dc8e3a74c86"}, + {file = "frozenlist-1.7.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61d1a5baeaac6c0798ff6edfaeaa00e0e412d49946c53fae8d4b8e8b3566c4ae"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7edf5c043c062462f09b6820de9854bf28cc6cc5b6714b383149745e287181a8"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:d50ac7627b3a1bd2dcef6f9da89a772694ec04d9a61b66cf87f7d9446b4a0c31"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ce48b2fece5aeb45265bb7a58259f45027db0abff478e3077e12b05b17fb9da7"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fe2365ae915a1fafd982c146754e1de6ab3478def8a59c86e1f7242d794f97d5"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:45a6f2fdbd10e074e8814eb98b05292f27bad7d1883afbe009d96abdcf3bc898"}, + {file = "frozenlist-1.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:21884e23cffabb157a9dd7e353779077bf5b8f9a58e9b262c6caad2ef5f80a56"}, + {file = "frozenlist-1.7.0-cp311-cp311-win32.whl", hash = "sha256:284d233a8953d7b24f9159b8a3496fc1ddc00f4db99c324bd5fb5f22d8698ea7"}, + {file = "frozenlist-1.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:387cbfdcde2f2353f19c2f66bbb52406d06ed77519ac7ee21be0232147c2592d"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3dbf9952c4bb0e90e98aec1bd992b3318685005702656bc6f67c1a32b76787f2"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f5906d3359300b8a9bb194239491122e6cf1444c2efb88865426f170c262cdb"}, + {file = "frozenlist-1.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3dabd5a8f84573c8d10d8859a50ea2dec01eea372031929871368c09fa103478"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa57daa5917f1738064f302bf2626281a1cb01920c32f711fbc7bc36111058a8"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c193dda2b6d49f4c4398962810fa7d7c78f032bf45572b3e04dd5249dff27e08"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfe2b675cf0aaa6d61bf8fbffd3c274b3c9b7b1623beb3809df8a81399a4a9c4"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8fc5d5cda37f62b262405cf9652cf0856839c4be8ee41be0afe8858f17f4c94b"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0d5ce521d1dd7d620198829b87ea002956e4319002ef0bc8d3e6d045cb4646e"}, + {file = "frozenlist-1.7.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:488d0a7d6a0008ca0db273c542098a0fa9e7dfaa7e57f70acef43f32b3f69dca"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:15a7eaba63983d22c54d255b854e8108e7e5f3e89f647fc854bd77a237e767df"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1eaa7e9c6d15df825bf255649e05bd8a74b04a4d2baa1ae46d9c2d00b2ca2cb5"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e4389e06714cfa9d47ab87f784a7c5be91d3934cd6e9a7b85beef808297cc025"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:73bd45e1488c40b63fe5a7df892baf9e2a4d4bb6409a2b3b78ac1c6236178e01"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99886d98e1643269760e5fe0df31e5ae7050788dd288947f7f007209b8c33f08"}, + {file = "frozenlist-1.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:290a172aae5a4c278c6da8a96222e6337744cd9c77313efe33d5670b9f65fc43"}, + {file = "frozenlist-1.7.0-cp312-cp312-win32.whl", hash = "sha256:426c7bc70e07cfebc178bc4c2bf2d861d720c4fff172181eeb4a4c41d4ca2ad3"}, + {file = "frozenlist-1.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:563b72efe5da92e02eb68c59cb37205457c977aa7a449ed1b37e6939e5c47c6a"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee80eeda5e2a4e660651370ebffd1286542b67e268aa1ac8d6dbe973120ef7ee"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d1a81c85417b914139e3a9b995d4a1c84559afc839a93cf2cb7f15e6e5f6ed2d"}, + {file = "frozenlist-1.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cbb65198a9132ebc334f237d7b0df163e4de83fb4f2bdfe46c1e654bdb0c5d43"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dab46c723eeb2c255a64f9dc05b8dd601fde66d6b19cdb82b2e09cc6ff8d8b5d"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6aeac207a759d0dedd2e40745575ae32ab30926ff4fa49b1635def65806fddee"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd8c4e58ad14b4fa7802b8be49d47993182fdd4023393899632c88fd8cd994eb"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04fb24d104f425da3540ed83cbfc31388a586a7696142004c577fa61c6298c3f"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6a5c505156368e4ea6b53b5ac23c92d7edc864537ff911d2fb24c140bb175e60"}, + {file = "frozenlist-1.7.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bd7eb96a675f18aa5c553eb7ddc24a43c8c18f22e1f9925528128c052cdbe00"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:05579bf020096fe05a764f1f84cd104a12f78eaab68842d036772dc6d4870b4b"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:376b6222d114e97eeec13d46c486facd41d4f43bab626b7c3f6a8b4e81a5192c"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0aa7e176ebe115379b5b1c95b4096fb1c17cce0847402e227e712c27bdb5a949"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3fbba20e662b9c2130dc771e332a99eff5da078b2b2648153a40669a6d0e36ca"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f3f4410a0a601d349dd406b5713fec59b4cee7e71678d5b17edda7f4655a940b"}, + {file = "frozenlist-1.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e2cdfaaec6a2f9327bf43c933c0319a7c429058e8537c508964a133dffee412e"}, + {file = "frozenlist-1.7.0-cp313-cp313-win32.whl", hash = "sha256:5fc4df05a6591c7768459caba1b342d9ec23fa16195e744939ba5914596ae3e1"}, + {file = "frozenlist-1.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:52109052b9791a3e6b5d1b65f4b909703984b770694d3eb64fad124c835d7cba"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a6f86e4193bb0e235ef6ce3dde5cbabed887e0b11f516ce8a0f4d3b33078ec2d"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:82d664628865abeb32d90ae497fb93df398a69bb3434463d172b80fc25b0dd7d"}, + {file = "frozenlist-1.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:912a7e8375a1c9a68325a902f3953191b7b292aa3c3fb0d71a216221deca460b"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9537c2777167488d539bc5de2ad262efc44388230e5118868e172dd4a552b146"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f34560fb1b4c3e30ba35fa9a13894ba39e5acfc5f60f57d8accde65f46cc5e74"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:acd03d224b0175f5a850edc104ac19040d35419eddad04e7cf2d5986d98427f1"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f2038310bc582f3d6a09b3816ab01737d60bf7b1ec70f5356b09e84fb7408ab1"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b8c05e4c8e5f36e5e088caa1bf78a687528f83c043706640a92cb76cd6999384"}, + {file = "frozenlist-1.7.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:765bb588c86e47d0b68f23c1bee323d4b703218037765dcf3f25c838c6fecceb"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:32dc2e08c67d86d0969714dd484fd60ff08ff81d1a1e40a77dd34a387e6ebc0c"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:c0303e597eb5a5321b4de9c68e9845ac8f290d2ab3f3e2c864437d3c5a30cd65"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:a47f2abb4e29b3a8d0b530f7c3598badc6b134562b1a5caee867f7c62fee51e3"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:3d688126c242a6fabbd92e02633414d40f50bb6002fa4cf995a1d18051525657"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4e7e9652b3d367c7bd449a727dc79d5043f48b88d0cbfd4f9f1060cf2b414104"}, + {file = "frozenlist-1.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1a85e345b4c43db8b842cab1feb41be5cc0b10a1830e6295b69d7310f99becaf"}, + {file = "frozenlist-1.7.0-cp313-cp313t-win32.whl", hash = "sha256:3a14027124ddb70dfcee5148979998066897e79f89f64b13328595c4bdf77c81"}, + {file = "frozenlist-1.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3bf8010d71d4507775f658e9823210b7427be36625b387221642725b515dcf3e"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:cea3dbd15aea1341ea2de490574a4a37ca080b2ae24e4b4f4b51b9057b4c3630"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7d536ee086b23fecc36c2073c371572374ff50ef4db515e4e503925361c24f71"}, + {file = "frozenlist-1.7.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dfcebf56f703cb2e346315431699f00db126d158455e513bd14089d992101e44"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:974c5336e61d6e7eb1ea5b929cb645e882aadab0095c5a6974a111e6479f8878"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c70db4a0ab5ab20878432c40563573229a7ed9241506181bba12f6b7d0dc41cb"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1137b78384eebaf70560a36b7b229f752fb64d463d38d1304939984d5cb887b6"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e793a9f01b3e8b5c0bc646fb59140ce0efcc580d22a3468d70766091beb81b35"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:74739ba8e4e38221d2c5c03d90a7e542cb8ad681915f4ca8f68d04f810ee0a87"}, + {file = "frozenlist-1.7.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e63344c4e929b1a01e29bc184bbb5fd82954869033765bfe8d65d09e336a677"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2ea2a7369eb76de2217a842f22087913cdf75f63cf1307b9024ab82dfb525938"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:836b42f472a0e006e02499cef9352ce8097f33df43baaba3e0a28a964c26c7d2"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e22b9a99741294b2571667c07d9f8cceec07cb92aae5ccda39ea1b6052ed4319"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:9a19e85cc503d958abe5218953df722748d87172f71b73cf3c9257a91b999890"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f22dac33bb3ee8fe3e013aa7b91dc12f60d61d05b7fe32191ffa84c3aafe77bd"}, + {file = "frozenlist-1.7.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ccec739a99e4ccf664ea0775149f2749b8a6418eb5b8384b4dc0a7d15d304cb"}, + {file = "frozenlist-1.7.0-cp39-cp39-win32.whl", hash = "sha256:b3950f11058310008a87757f3eee16a8e1ca97979833239439586857bc25482e"}, + {file = "frozenlist-1.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:43a82fce6769c70f2f5a06248b614a7d268080a9d20f7457ef10ecee5af82b63"}, + {file = "frozenlist-1.7.0-py3-none-any.whl", hash = "sha256:9a5af342e34f7e97caf8c995864c7a396418ae2859cc6fdf1b1073020d516a7e"}, + {file = "frozenlist-1.7.0.tar.gz", hash = "sha256:2e310d81923c2437ea8670467121cc3e9b0f76d3043cc1d2331d56c7fb7a3a8f"}, +] + +[[package]] +name = "fsspec" +version = "2025.7.0" +description = "File-system specification" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "fsspec-2025.7.0-py3-none-any.whl", hash = "sha256:8b012e39f63c7d5f10474de957f3ab793b47b45ae7d39f2fb735f8bbe25c0e21"}, + {file = "fsspec-2025.7.0.tar.gz", hash = "sha256:786120687ffa54b8283d942929540d8bc5ccfa820deb555a2b5d0ed2b737bf58"}, +] + +[package.extras] +abfs = ["adlfs"] +adl = ["adlfs"] +arrow = ["pyarrow (>=1)"] +dask = ["dask", "distributed"] +dev = ["pre-commit", "ruff (>=0.5)"] +doc = ["numpydoc", "sphinx", "sphinx-design", "sphinx-rtd-theme", "yarl"] +dropbox = ["dropbox", "dropboxdrivefs", "requests"] +full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "dask", "distributed", "dropbox", "dropboxdrivefs", "fusepy", "gcsfs", "libarchive-c", "ocifs", "panel", "paramiko", "pyarrow (>=1)", "pygit2", "requests", "s3fs", "smbprotocol", "tqdm"] +fuse = ["fusepy"] +gcs = ["gcsfs"] +git = ["pygit2"] +github = ["requests"] +gs = ["gcsfs"] +gui = ["panel"] +hdfs = ["pyarrow (>=1)"] +http = ["aiohttp (!=4.0.0a0,!=4.0.0a1)"] +libarchive = ["libarchive-c"] +oci = ["ocifs"] +s3 = ["s3fs"] +sftp = ["paramiko"] +smb = ["smbprotocol"] +ssh = ["paramiko"] +test = ["aiohttp (!=4.0.0a0,!=4.0.0a1)", "numpy", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "requests"] +test-downstream = ["aiobotocore (>=2.5.4,<3.0.0)", "dask[dataframe,test]", "moto[server] (>4,<5)", "pytest-timeout", "xarray"] +test-full = ["adlfs", "aiohttp (!=4.0.0a0,!=4.0.0a1)", "cloudpickle", "dask", "distributed", "dropbox", "dropboxdrivefs", "fastparquet", "fusepy", "gcsfs", "jinja2", "kerchunk", "libarchive-c", "lz4", "notebook", "numpy", "ocifs", "pandas", "panel", "paramiko", "pyarrow", "pyarrow (>=1)", "pyftpdlib", "pygit2", "pytest", "pytest-asyncio (!=0.22.0)", "pytest-benchmark", "pytest-cov", "pytest-mock", "pytest-recording", "pytest-rerunfailures", "python-snappy", "requests", "smbprotocol", "tqdm", "urllib3", "zarr", "zstandard ; python_version < \"3.14\""] +tqdm = ["tqdm"] + +[[package]] +name = "gcloud-aio-auth" +version = "5.4.2" +description = "Python Client for Google Cloud Auth" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "gcloud_aio_auth-5.4.2-py3-none-any.whl", hash = "sha256:3adfb6ee5cae4226689fd096ce127e99ee5216623577215abb02ef6722574563"}, + {file = "gcloud_aio_auth-5.4.2.tar.gz", hash = "sha256:184478d081f7cfbb6eff421c22d877d48d17811fa88b269f0c016f5528b3fa31"}, +] + +[package.dependencies] +aiohttp = ">=3.9.0,<4.0.0" +backoff = ">=1.0.0,<3.0.0" +chardet = ">=2.0,<6.0" +cryptography = ">=2.0.0,<47.0.0" +pyjwt = ">=1.5.3,<3.0.0" + +[[package]] +name = "gcloud-aio-storage" +version = "9.5.0" +description = "Python Client for Google Cloud Storage" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5b6a66c18b5b9dd261ca98dffcb826a525334b2f29e7caa54e182255c5f6a65a"}, - {file = "frozenlist-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d1b3eb7b05ea246510b43a7e53ed1653e55c2121019a97e60cad7efb881a97bb"}, - {file = "frozenlist-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15538c0cbf0e4fa11d1e3a71f823524b0c46299aed6e10ebb4c2089abd8c3bec"}, - {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e79225373c317ff1e35f210dd5f1344ff31066ba8067c307ab60254cd3a78ad5"}, - {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9272fa73ca71266702c4c3e2d4a28553ea03418e591e377a03b8e3659d94fa76"}, - {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:498524025a5b8ba81695761d78c8dd7382ac0b052f34e66939c42df860b8ff17"}, - {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:92b5278ed9d50fe610185ecd23c55d8b307d75ca18e94c0e7de328089ac5dcba"}, - {file = "frozenlist-1.5.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f3c8c1dacd037df16e85227bac13cca58c30da836c6f936ba1df0c05d046d8d"}, - {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f2ac49a9bedb996086057b75bf93538240538c6d9b38e57c82d51f75a73409d2"}, - {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e66cc454f97053b79c2ab09c17fbe3c825ea6b4de20baf1be28919460dd7877f"}, - {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:5a3ba5f9a0dfed20337d3e966dc359784c9f96503674c2faf015f7fe8e96798c"}, - {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6321899477db90bdeb9299ac3627a6a53c7399c8cd58d25da094007402b039ab"}, - {file = "frozenlist-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76e4753701248476e6286f2ef492af900ea67d9706a0155335a40ea21bf3b2f5"}, - {file = "frozenlist-1.5.0-cp310-cp310-win32.whl", hash = "sha256:977701c081c0241d0955c9586ffdd9ce44f7a7795df39b9151cd9a6fd0ce4cfb"}, - {file = "frozenlist-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:189f03b53e64144f90990d29a27ec4f7997d91ed3d01b51fa39d2dbe77540fd4"}, - {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd74520371c3c4175142d02a976aee0b4cb4a7cc912a60586ffd8d5929979b30"}, - {file = "frozenlist-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f3f7a0fbc219fb4455264cae4d9f01ad41ae6ee8524500f381de64ffaa077d5"}, - {file = "frozenlist-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f47c9c9028f55a04ac254346e92977bf0f166c483c74b4232bee19a6697e4778"}, - {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0996c66760924da6e88922756d99b47512a71cfd45215f3570bf1e0b694c206a"}, - {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a2fe128eb4edeabe11896cb6af88fca5346059f6c8d807e3b910069f39157869"}, - {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a8ea951bbb6cacd492e3948b8da8c502a3f814f5d20935aae74b5df2b19cf3d"}, - {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:de537c11e4aa01d37db0d403b57bd6f0546e71a82347a97c6a9f0dcc532b3a45"}, - {file = "frozenlist-1.5.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c2623347b933fcb9095841f1cc5d4ff0b278addd743e0e966cb3d460278840d"}, - {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cee6798eaf8b1416ef6909b06f7dc04b60755206bddc599f52232606e18179d3"}, - {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:f5f9da7f5dbc00a604fe74aa02ae7c98bcede8a3b8b9666f9f86fc13993bc71a"}, - {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:90646abbc7a5d5c7c19461d2e3eeb76eb0b204919e6ece342feb6032c9325ae9"}, - {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bdac3c7d9b705d253b2ce370fde941836a5f8b3c5c2b8fd70940a3ea3af7f4f2"}, - {file = "frozenlist-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03d33c2ddbc1816237a67f66336616416e2bbb6beb306e5f890f2eb22b959cdf"}, - {file = "frozenlist-1.5.0-cp311-cp311-win32.whl", hash = "sha256:237f6b23ee0f44066219dae14c70ae38a63f0440ce6750f868ee08775073f942"}, - {file = "frozenlist-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:0cc974cc93d32c42e7b0f6cf242a6bd941c57c61b618e78b6c0a96cb72788c1d"}, - {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:31115ba75889723431aa9a4e77d5f398f5cf976eea3bdf61749731f62d4a4a21"}, - {file = "frozenlist-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7437601c4d89d070eac8323f121fcf25f88674627505334654fd027b091db09d"}, - {file = "frozenlist-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7948140d9f8ece1745be806f2bfdf390127cf1a763b925c4a805c603df5e697e"}, - {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feeb64bc9bcc6b45c6311c9e9b99406660a9c05ca8a5b30d14a78555088b0b3a"}, - {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:683173d371daad49cffb8309779e886e59c2f369430ad28fe715f66d08d4ab1a"}, - {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7d57d8f702221405a9d9b40f9da8ac2e4a1a8b5285aac6100f3393675f0a85ee"}, - {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30c72000fbcc35b129cb09956836c7d7abf78ab5416595e4857d1cae8d6251a6"}, - {file = "frozenlist-1.5.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:000a77d6034fbad9b6bb880f7ec073027908f1b40254b5d6f26210d2dab1240e"}, - {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5d7f5a50342475962eb18b740f3beecc685a15b52c91f7d975257e13e029eca9"}, - {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:87f724d055eb4785d9be84e9ebf0f24e392ddfad00b3fe036e43f489fafc9039"}, - {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6e9080bb2fb195a046e5177f10d9d82b8a204c0736a97a153c2466127de87784"}, - {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b93d7aaa36c966fa42efcaf716e6b3900438632a626fb09c049f6a2f09fc631"}, - {file = "frozenlist-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:52ef692a4bc60a6dd57f507429636c2af8b6046db8b31b18dac02cbc8f507f7f"}, - {file = "frozenlist-1.5.0-cp312-cp312-win32.whl", hash = "sha256:29d94c256679247b33a3dc96cce0f93cbc69c23bf75ff715919332fdbb6a32b8"}, - {file = "frozenlist-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:8969190d709e7c48ea386db202d708eb94bdb29207a1f269bab1196ce0dcca1f"}, - {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:7a1a048f9215c90973402e26c01d1cff8a209e1f1b53f72b95c13db61b00f953"}, - {file = "frozenlist-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dd47a5181ce5fcb463b5d9e17ecfdb02b678cca31280639255ce9d0e5aa67af0"}, - {file = "frozenlist-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1431d60b36d15cda188ea222033eec8e0eab488f39a272461f2e6d9e1a8e63c2"}, - {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6482a5851f5d72767fbd0e507e80737f9c8646ae7fd303def99bfe813f76cf7f"}, - {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:44c49271a937625619e862baacbd037a7ef86dd1ee215afc298a417ff3270608"}, - {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12f78f98c2f1c2429d42e6a485f433722b0061d5c0b0139efa64f396efb5886b"}, - {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce3aa154c452d2467487765e3adc730a8c153af77ad84096bc19ce19a2400840"}, - {file = "frozenlist-1.5.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b7dc0c4338e6b8b091e8faf0db3168a37101943e687f373dce00959583f7439"}, - {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:45e0896250900b5aa25180f9aec243e84e92ac84bd4a74d9ad4138ef3f5c97de"}, - {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:561eb1c9579d495fddb6da8959fd2a1fca2c6d060d4113f5844b433fc02f2641"}, - {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:df6e2f325bfee1f49f81aaac97d2aa757c7646534a06f8f577ce184afe2f0a9e"}, - {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:140228863501b44b809fb39ec56b5d4071f4d0aa6d216c19cbb08b8c5a7eadb9"}, - {file = "frozenlist-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7707a25d6a77f5d27ea7dc7d1fc608aa0a478193823f88511ef5e6b8a48f9d03"}, - {file = "frozenlist-1.5.0-cp313-cp313-win32.whl", hash = "sha256:31a9ac2b38ab9b5a8933b693db4939764ad3f299fcaa931a3e605bc3460e693c"}, - {file = "frozenlist-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:11aabdd62b8b9c4b84081a3c246506d1cddd2dd93ff0ad53ede5defec7886b28"}, - {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:dd94994fc91a6177bfaafd7d9fd951bc8689b0a98168aa26b5f543868548d3ca"}, - {file = "frozenlist-1.5.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2d0da8bbec082bf6bf18345b180958775363588678f64998c2b7609e34719b10"}, - {file = "frozenlist-1.5.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:73f2e31ea8dd7df61a359b731716018c2be196e5bb3b74ddba107f694fbd7604"}, - {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:828afae9f17e6de596825cf4228ff28fbdf6065974e5ac1410cecc22f699d2b3"}, - {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1577515d35ed5649d52ab4319db757bb881ce3b2b796d7283e6634d99ace307"}, - {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2150cc6305a2c2ab33299453e2968611dacb970d2283a14955923062c8d00b10"}, - {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a72b7a6e3cd2725eff67cd64c8f13335ee18fc3c7befc05aed043d24c7b9ccb9"}, - {file = "frozenlist-1.5.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c16d2fa63e0800723139137d667e1056bee1a1cf7965153d2d104b62855e9b99"}, - {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:17dcc32fc7bda7ce5875435003220a457bcfa34ab7924a49a1c19f55b6ee185c"}, - {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:97160e245ea33d8609cd2b8fd997c850b56db147a304a262abc2b3be021a9171"}, - {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f1e6540b7fa044eee0bb5111ada694cf3dc15f2b0347ca125ee9ca984d5e9e6e"}, - {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:91d6c171862df0a6c61479d9724f22efb6109111017c87567cfeb7b5d1449fdf"}, - {file = "frozenlist-1.5.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c1fac3e2ace2eb1052e9f7c7db480818371134410e1f5c55d65e8f3ac6d1407e"}, - {file = "frozenlist-1.5.0-cp38-cp38-win32.whl", hash = "sha256:b97f7b575ab4a8af9b7bc1d2ef7f29d3afee2226bd03ca3875c16451ad5a7723"}, - {file = "frozenlist-1.5.0-cp38-cp38-win_amd64.whl", hash = "sha256:374ca2dabdccad8e2a76d40b1d037f5bd16824933bf7bcea3e59c891fd4a0923"}, - {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9bbcdfaf4af7ce002694a4e10a0159d5a8d20056a12b05b45cea944a4953f972"}, - {file = "frozenlist-1.5.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1893f948bf6681733aaccf36c5232c231e3b5166d607c5fa77773611df6dc336"}, - {file = "frozenlist-1.5.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2b5e23253bb709ef57a8e95e6ae48daa9ac5f265637529e4ce6b003a37b2621f"}, - {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f253985bb515ecd89629db13cb58d702035ecd8cfbca7d7a7e29a0e6d39af5f"}, - {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:04a5c6babd5e8fb7d3c871dc8b321166b80e41b637c31a995ed844a6139942b6"}, - {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9fe0f1c29ba24ba6ff6abf688cb0b7cf1efab6b6aa6adc55441773c252f7411"}, - {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:226d72559fa19babe2ccd920273e767c96a49b9d3d38badd7c91a0fdeda8ea08"}, - {file = "frozenlist-1.5.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15b731db116ab3aedec558573c1a5eec78822b32292fe4f2f0345b7f697745c2"}, - {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:366d8f93e3edfe5a918c874702f78faac300209a4d5bf38352b2c1bdc07a766d"}, - {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:1b96af8c582b94d381a1c1f51ffaedeb77c821c690ea5f01da3d70a487dd0a9b"}, - {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:c03eff4a41bd4e38415cbed054bbaff4a075b093e2394b6915dca34a40d1e38b"}, - {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:50cf5e7ee9b98f22bdecbabf3800ae78ddcc26e4a435515fc72d97903e8488e0"}, - {file = "frozenlist-1.5.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1e76bfbc72353269c44e0bc2cfe171900fbf7f722ad74c9a7b638052afe6a00c"}, - {file = "frozenlist-1.5.0-cp39-cp39-win32.whl", hash = "sha256:666534d15ba8f0fda3f53969117383d5dc021266b3c1a42c9ec4855e4b58b9d3"}, - {file = "frozenlist-1.5.0-cp39-cp39-win_amd64.whl", hash = "sha256:5c28f4b5dbef8a0d8aad0d4de24d1e9e981728628afaf4ea0792f5d0939372f0"}, - {file = "frozenlist-1.5.0-py3-none-any.whl", hash = "sha256:d994863bba198a4a518b467bb971c56e1db3f180a25c6cf7bb1949c267f748c3"}, - {file = "frozenlist-1.5.0.tar.gz", hash = "sha256:81d5af29e61b9c8348e876d442253723928dce6433e0e76cd925cd83f1b4b817"}, + {file = "gcloud_aio_storage-9.5.0-py3-none-any.whl", hash = "sha256:cf65e60d69ff1b9de67c2e985126b60866611551e49b6cbf1a53bc3c85421632"}, + {file = "gcloud_aio_storage-9.5.0.tar.gz", hash = "sha256:5adbb21818f1236169fea4958e14b7ace59f81262b112d080cf76030cbef5f34"}, ] +[package.dependencies] +aiofiles = ">=0.6.0,<25.0.0" +gcloud-aio-auth = ">=5.3.0,<6.0.0" +pyasn1-modules = ">=0.2.1,<0.4.2" +rsa = ">=3.1.4,<5.0.0" + [[package]] name = "google-api-core" -version = "2.24.0" +version = "2.25.1" description = "Google API client core library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_api_core-2.24.0-py3-none-any.whl", hash = "sha256:10d82ac0fca69c82a25b3efdeefccf6f28e02ebb97925a8cce8edbfe379929d9"}, - {file = "google_api_core-2.24.0.tar.gz", hash = "sha256:e255640547a597a4da010876d333208ddac417d60add22b6851a0c66a831fcaf"}, + {file = "google_api_core-2.25.1-py3-none-any.whl", hash = "sha256:8a2a56c1fef82987a524371f99f3bd0143702fecc670c72e600c1cda6bf8dbb7"}, + {file = "google_api_core-2.25.1.tar.gz", hash = "sha256:d2aaa0b13c78c61cb3f4282c464c046e45fbd75755683c9c525e6e8f7ed0a5e8"}, ] [package.dependencies] -google-auth = ">=2.14.1,<3.0.dev0" -googleapis-common-protos = ">=1.56.2,<2.0.dev0" +google-auth = ">=2.14.1,<3.0.0" +googleapis-common-protos = ">=1.56.2,<2.0.0" grpcio = [ - {version = ">=1.49.1,<2.0dev", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, - {version = ">=1.33.2,<2.0dev", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0.0", optional = true, markers = "extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, ] grpcio-status = [ - {version = ">=1.49.1,<2.0.dev0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, - {version = ">=1.33.2,<2.0.dev0", optional = true, markers = "python_version < \"3.11\" and extra == \"grpc\""}, + {version = ">=1.33.2,<2.0.0", optional = true, markers = "extra == \"grpc\""}, + {version = ">=1.49.1,<2.0.0", optional = true, markers = "python_version >= \"3.11\" and extra == \"grpc\""}, +] +proto-plus = [ + {version = ">=1.22.3,<2.0.0"}, + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, ] -proto-plus = ">=1.22.3,<2.0.0dev" -protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" -requests = ">=2.18.0,<3.0.0.dev0" +protobuf = ">=3.19.5,<3.20.0 || >3.20.0,<3.20.1 || >3.20.1,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" +requests = ">=2.18.0,<3.0.0" [package.extras] -async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.dev0)"] -grpc = ["grpcio (>=1.33.2,<2.0dev)", "grpcio (>=1.49.1,<2.0dev)", "grpcio-status (>=1.33.2,<2.0.dev0)", "grpcio-status (>=1.49.1,<2.0.dev0)"] -grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] -grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.dev0)"] +async-rest = ["google-auth[aiohttp] (>=2.35.0,<3.0.0)"] +grpc = ["grpcio (>=1.33.2,<2.0.0)", "grpcio (>=1.49.1,<2.0.0) ; python_version >= \"3.11\"", "grpcio-status (>=1.33.2,<2.0.0)", "grpcio-status (>=1.49.1,<2.0.0) ; python_version >= \"3.11\""] +grpcgcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] +grpcio-gcp = ["grpcio-gcp (>=0.2.2,<1.0.0)"] [[package]] name = "google-api-python-client" -version = "2.155.0" +version = "2.177.0" description = "Google API Client Library for Python" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_api_python_client-2.155.0-py2.py3-none-any.whl", hash = "sha256:83fe9b5aa4160899079d7c93a37be306546a17e6686e2549bcc9584f1a229747"}, - {file = "google_api_python_client-2.155.0.tar.gz", hash = "sha256:25529f89f0d13abcf3c05c089c423fb2858ac16e0b3727543393468d0d7af67c"}, + {file = "google_api_python_client-2.177.0-py3-none-any.whl", hash = "sha256:f2f50f11105ab883eb9b6cf38ec54ea5fd4b429249f76444bec90deba5be79b3"}, + {file = "google_api_python_client-2.177.0.tar.gz", hash = "sha256:9ffd2b57d68f5afa7e6ac64e2c440534eaa056cbb394812a62ff94723c31b50e"}, ] [package.dependencies] -google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0.dev0" -google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0.dev0" +google-api-core = ">=1.31.5,<2.0.dev0 || >2.3.0,<3.0.0" +google-auth = ">=1.32.0,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" google-auth-httplib2 = ">=0.2.0,<1.0.0" -httplib2 = ">=0.19.0,<1.dev0" +httplib2 = ">=0.19.0,<1.0.0" uritemplate = ">=3.0.1,<5" [[package]] name = "google-auth" -version = "2.37.0" +version = "2.40.3" description = "Google Authentication Library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_auth-2.37.0-py2.py3-none-any.whl", hash = "sha256:42664f18290a6be591be5329a96fe30184be1a1badb7292a7f686a9659de9ca0"}, - {file = "google_auth-2.37.0.tar.gz", hash = "sha256:0054623abf1f9c83492c63d3f47e77f0a544caa3d40b2d98e099a611c2dd5d00"}, + {file = "google_auth-2.40.3-py2.py3-none-any.whl", hash = "sha256:1370d4593e86213563547f97a92752fc658456fe4514c809544f330fed45a7ca"}, + {file = "google_auth-2.40.3.tar.gz", hash = "sha256:500c3a29adedeb36ea9cf24b8d10858e152f2412e3ca37829b3fa18e33d63b77"}, ] [package.dependencies] @@ -1013,12 +1629,14 @@ pyasn1-modules = ">=0.2.1" rsa = ">=3.1.4,<5" [package.extras] -aiohttp = ["aiohttp (>=3.6.2,<4.0.0.dev0)", "requests (>=2.20.0,<3.0.0.dev0)"] +aiohttp = ["aiohttp (>=3.6.2,<4.0.0)", "requests (>=2.20.0,<3.0.0)"] enterprise-cert = ["cryptography", "pyopenssl"] -pyjwt = ["cryptography (>=38.0.3)", "pyjwt (>=2.0)"] -pyopenssl = ["cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] +pyjwt = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyjwt (>=2.0)"] +pyopenssl = ["cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "pyopenssl (>=20.0.0)"] reauth = ["pyu2f (>=0.1.5)"] -requests = ["requests (>=2.20.0,<3.0.0.dev0)"] +requests = ["requests (>=2.20.0,<3.0.0)"] +testing = ["aiohttp (<3.10.0)", "aiohttp (>=3.6.2,<4.0.0)", "aioresponses", "cryptography (<39.0.0) ; python_version < \"3.8\"", "cryptography (>=38.0.3)", "flask", "freezegun", "grpcio", "mock", "oauth2client", "packaging", "pyjwt (>=2.0)", "pyopenssl (<24.3.0)", "pyopenssl (>=20.0.0)", "pytest", "pytest-asyncio", "pytest-cov", "pytest-localserver", "pyu2f (>=0.1.5)", "requests (>=2.20.0,<3.0.0)", "responses", "urllib3"] +urllib3 = ["packaging", "urllib3"] [[package]] name = "google-auth-httplib2" @@ -1026,6 +1644,7 @@ version = "0.2.0" description = "Google Authentication Library: httplib2 transport" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "google-auth-httplib2-0.2.0.tar.gz", hash = "sha256:38aa7badf48f974f1eb9861794e9c0cb2a0511a4ec0679b1f886d108f5640e05"}, {file = "google_auth_httplib2-0.2.0-py2.py3-none-any.whl", hash = "sha256:b65a0a2123300dd71281a7bf6e64d65a0759287df52729bdd1ae2e47dc311a3d"}, @@ -1037,13 +1656,14 @@ httplib2 = ">=0.19.0" [[package]] name = "google-auth-oauthlib" -version = "1.2.1" +version = "1.2.2" description = "Google Authentication Library" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ - {file = "google_auth_oauthlib-1.2.1-py2.py3-none-any.whl", hash = "sha256:2d58a27262d55aa1b87678c3ba7142a080098cbc2024f903c62355deb235d91f"}, - {file = "google_auth_oauthlib-1.2.1.tar.gz", hash = "sha256:afd0cad092a2eaa53cd8e8298557d6de1034c6cb4a740500b5357b648af97263"}, + {file = "google_auth_oauthlib-1.2.2-py3-none-any.whl", hash = "sha256:fd619506f4b3908b5df17b65f39ca8d66ea56986e5472eb5978fd8f3786f00a2"}, + {file = "google_auth_oauthlib-1.2.2.tar.gz", hash = "sha256:11046fb8d3348b296302dd939ace8af0a724042e8029c1b872d87fabc9f41684"}, ] [package.dependencies] @@ -1055,45 +1675,51 @@ tool = ["click (>=6.0.0)"] [[package]] name = "google-cloud-appengine-logging" -version = "1.5.0" +version = "1.6.2" description = "Google Cloud Appengine Logging API client library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_cloud_appengine_logging-1.5.0-py2.py3-none-any.whl", hash = "sha256:81e36606e13c377c4898c918542888abb7a6896837ac5f559011c7729fc63d8a"}, - {file = "google_cloud_appengine_logging-1.5.0.tar.gz", hash = "sha256:39a2df694d97981ed00ef5df541f7cfcca920a92496707557f2b07bb7ba9d67a"}, + {file = "google_cloud_appengine_logging-1.6.2-py3-none-any.whl", hash = "sha256:2b28ed715e92b67e334c6fcfe1deb523f001919560257b25fc8fcda95fd63938"}, + {file = "google_cloud_appengine_logging-1.6.2.tar.gz", hash = "sha256:4890928464c98da9eecc7bf4e0542eba2551512c0265462c10f3a3d2a6424b90"}, ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" -proto-plus = ">=1.22.3,<2.0.0dev" -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +proto-plus = [ + {version = ">=1.22.3,<2.0.0"}, + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, +] +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [[package]] name = "google-cloud-audit-log" -version = "0.3.0" +version = "0.3.2" description = "Google Cloud Audit Protos" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_cloud_audit_log-0.3.0-py2.py3-none-any.whl", hash = "sha256:8340793120a1d5aa143605def8704ecdcead15106f754ef1381ae3bab533722f"}, - {file = "google_cloud_audit_log-0.3.0.tar.gz", hash = "sha256:901428b257020d8c1d1133e0fa004164a555e5a395c7ca3cdbb8486513df3a65"}, + {file = "google_cloud_audit_log-0.3.2-py3-none-any.whl", hash = "sha256:daaedfb947a0d77f524e1bd2b560242ab4836fe1afd6b06b92f152b9658554ed"}, + {file = "google_cloud_audit_log-0.3.2.tar.gz", hash = "sha256:2598f1533a7d7cdd6c7bf448c12e5519c1d53162d78784e10bcdd1df67791bc3"}, ] [package.dependencies] -googleapis-common-protos = ">=1.56.2,<2.0dev" -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +googleapis-common-protos = ">=1.56.2,<2.0.0" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [[package]] name = "google-cloud-core" -version = "2.4.1" +version = "2.4.3" description = "Google Cloud API client core library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073"}, - {file = "google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61"}, + {file = "google_cloud_core-2.4.3-py2.py3-none-any.whl", hash = "sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e"}, + {file = "google_cloud_core-2.4.3.tar.gz", hash = "sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53"}, ] [package.dependencies] @@ -1105,86 +1731,97 @@ grpc = ["grpcio (>=1.38.0,<2.0dev)", "grpcio-status (>=1.38.0,<2.0.dev0)"] [[package]] name = "google-cloud-logging" -version = "3.11.3" +version = "3.12.1" description = "Stackdriver Logging API client library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_cloud_logging-3.11.3-py2.py3-none-any.whl", hash = "sha256:b8ec23f2998f76a58f8492db26a0f4151dd500425c3f08448586b85972f3c494"}, - {file = "google_cloud_logging-3.11.3.tar.gz", hash = "sha256:0a73cd94118875387d4535371d9e9426861edef8e44fba1261e86782d5b8d54f"}, + {file = "google_cloud_logging-3.12.1-py2.py3-none-any.whl", hash = "sha256:6817878af76ec4e7568976772839ab2c43ddfd18fbbf2ce32b13ef549cd5a862"}, + {file = "google_cloud_logging-3.12.1.tar.gz", hash = "sha256:36efc823985055b203904e83e1c8f9f999b3c64270bcda39d57386ca4effd678"}, ] [package.dependencies] -google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0dev", extras = ["grpc"]} -google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0dev" -google-cloud-appengine-logging = ">=0.1.3,<2.0.0dev" -google-cloud-audit-log = ">=0.2.4,<1.0.0dev" -google-cloud-core = ">=2.0.0,<3.0.0dev" -grpc-google-iam-v1 = ">=0.12.4,<1.0.0dev" +google-api-core = {version = ">=1.34.1,<2.0.dev0 || >=2.11.dev0,<3.0.0", extras = ["grpc"]} +google-auth = ">=2.14.1,<2.24.0 || >2.24.0,<2.25.0 || >2.25.0,<3.0.0" +google-cloud-appengine-logging = ">=0.1.3,<2.0.0" +google-cloud-audit-log = ">=0.3.1,<1.0.0" +google-cloud-core = ">=2.0.0,<3.0.0" +grpc-google-iam-v1 = ">=0.12.4,<1.0.0" opentelemetry-api = ">=1.9.0" proto-plus = [ - {version = ">=1.22.2,<2.0.0dev", markers = "python_version >= \"3.11\""}, - {version = ">=1.22.0,<2.0.0dev", markers = "python_version < \"3.11\""}, + {version = ">=1.22.0,<2.0.0"}, + {version = ">=1.22.2,<2.0.0", markers = "python_version >= \"3.11\""}, + {version = ">=1.25.0,<2.0.0", markers = "python_version >= \"3.13\""}, ] -protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +protobuf = ">=3.20.2,<4.21.0 || >4.21.0,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [[package]] name = "google-cloud-storage" -version = "2.19.0" +version = "3.2.0" description = "Google Cloud Storage API client library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "google_cloud_storage-2.19.0-py2.py3-none-any.whl", hash = "sha256:aeb971b5c29cf8ab98445082cbfe7b161a1f48ed275822f59ed3f1524ea54fba"}, - {file = "google_cloud_storage-2.19.0.tar.gz", hash = "sha256:cd05e9e7191ba6cb68934d8eb76054d9be4562aa89dbc4236feee4d7d51342b2"}, + {file = "google_cloud_storage-3.2.0-py3-none-any.whl", hash = "sha256:ff7a9a49666954a7c3d1598291220c72d3b9e49d9dfcf9dfaecb301fc4fb0b24"}, + {file = "google_cloud_storage-3.2.0.tar.gz", hash = "sha256:decca843076036f45633198c125d1861ffbf47ebf5c0e3b98dcb9b2db155896c"}, ] [package.dependencies] -google-api-core = ">=2.15.0,<3.0.0dev" -google-auth = ">=2.26.1,<3.0dev" -google-cloud-core = ">=2.3.0,<3.0dev" -google-crc32c = ">=1.0,<2.0dev" -google-resumable-media = ">=2.7.2" -requests = ">=2.18.0,<3.0.0dev" +google-api-core = ">=2.15.0,<3.0.0" +google-auth = ">=2.26.1,<3.0.0" +google-cloud-core = ">=2.4.2,<3.0.0" +google-crc32c = ">=1.1.3,<2.0.0" +google-resumable-media = ">=2.7.2,<3.0.0" +requests = ">=2.22.0,<3.0.0" [package.extras] -protobuf = ["protobuf (<6.0.0dev)"] -tracing = ["opentelemetry-api (>=1.1.0)"] +protobuf = ["protobuf (>=3.20.2,<7.0.0)"] +tracing = ["opentelemetry-api (>=1.1.0,<2.0.0)"] [[package]] name = "google-crc32c" -version = "1.6.0" +version = "1.7.1" description = "A python wrapper of the C library 'Google CRC32C'" optional = false python-versions = ">=3.9" -files = [ - {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:5bcc90b34df28a4b38653c36bb5ada35671ad105c99cfe915fb5bed7ad6924aa"}, - {file = "google_crc32c-1.6.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:d9e9913f7bd69e093b81da4535ce27af842e7bf371cde42d1ae9e9bd382dc0e9"}, - {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:a184243544811e4a50d345838a883733461e67578959ac59964e43cca2c791e7"}, - {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:236c87a46cdf06384f614e9092b82c05f81bd34b80248021f729396a78e55d7e"}, - {file = "google_crc32c-1.6.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ebab974b1687509e5c973b5c4b8b146683e101e102e17a86bd196ecaa4d099fc"}, - {file = "google_crc32c-1.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:50cf2a96da226dcbff8671233ecf37bf6e95de98b2a2ebadbfdf455e6d05df42"}, - {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f7a1fc29803712f80879b0806cb83ab24ce62fc8daf0569f2204a0cfd7f68ed4"}, - {file = "google_crc32c-1.6.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:40b05ab32a5067525670880eb5d169529089a26fe35dce8891127aeddc1950e8"}, - {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9e4b426c3702f3cd23b933436487eb34e01e00327fac20c9aebb68ccf34117d"}, - {file = "google_crc32c-1.6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51c4f54dd8c6dfeb58d1df5e4f7f97df8abf17a36626a217f169893d1d7f3e9f"}, - {file = "google_crc32c-1.6.0-cp311-cp311-win_amd64.whl", hash = "sha256:bb8b3c75bd157010459b15222c3fd30577042a7060e29d42dabce449c087f2b3"}, - {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ed767bf4ba90104c1216b68111613f0d5926fb3780660ea1198fc469af410e9d"}, - {file = "google_crc32c-1.6.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:62f6d4a29fea082ac4a3c9be5e415218255cf11684ac6ef5488eea0c9132689b"}, - {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c87d98c7c4a69066fd31701c4e10d178a648c2cac3452e62c6b24dc51f9fcc00"}, - {file = "google_crc32c-1.6.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd5e7d2445d1a958c266bfa5d04c39932dc54093fa391736dbfdb0f1929c1fb3"}, - {file = "google_crc32c-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:7aec8e88a3583515f9e0957fe4f5f6d8d4997e36d0f61624e70469771584c760"}, - {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e2806553238cd076f0a55bddab37a532b53580e699ed8e5606d0de1f856b5205"}, - {file = "google_crc32c-1.6.0-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:bb0966e1c50d0ef5bc743312cc730b533491d60585a9a08f897274e57c3f70e0"}, - {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:386122eeaaa76951a8196310432c5b0ef3b53590ef4c317ec7588ec554fec5d2"}, - {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2952396dc604544ea7476b33fe87faedc24d666fb0c2d5ac971a2b9576ab871"}, - {file = "google_crc32c-1.6.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:35834855408429cecf495cac67ccbab802de269e948e27478b1e47dfb6465e57"}, - {file = "google_crc32c-1.6.0-cp39-cp39-win_amd64.whl", hash = "sha256:d8797406499f28b5ef791f339594b0b5fdedf54e203b5066675c406ba69d705c"}, - {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48abd62ca76a2cbe034542ed1b6aee851b6f28aaca4e6551b5599b6f3ef175cc"}, - {file = "google_crc32c-1.6.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18e311c64008f1f1379158158bb3f0c8d72635b9eb4f9545f8cf990c5668e59d"}, - {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05e2d8c9a2f853ff116db9706b4a27350587f341eda835f46db3c0a8c8ce2f24"}, - {file = "google_crc32c-1.6.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91ca8145b060679ec9176e6de4f89b07363d6805bd4760631ef254905503598d"}, - {file = "google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc"}, +groups = ["main"] +files = [ + {file = "google_crc32c-1.7.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:b07d48faf8292b4db7c3d64ab86f950c2e94e93a11fd47271c28ba458e4a0d76"}, + {file = "google_crc32c-1.7.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:7cc81b3a2fbd932a4313eb53cc7d9dde424088ca3a0337160f35d91826880c1d"}, + {file = "google_crc32c-1.7.1-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1c67ca0a1f5b56162951a9dae987988679a7db682d6f97ce0f6381ebf0fbea4c"}, + {file = "google_crc32c-1.7.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc5319db92daa516b653600794d5b9f9439a9a121f3e162f94b0e1891c7933cb"}, + {file = "google_crc32c-1.7.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcdf5a64adb747610140572ed18d011896e3b9ae5195f2514b7ff678c80f1603"}, + {file = "google_crc32c-1.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:754561c6c66e89d55754106739e22fdaa93fafa8da7221b29c8b8e8270c6ec8a"}, + {file = "google_crc32c-1.7.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:6fbab4b935989e2c3610371963ba1b86afb09537fd0c633049be82afe153ac06"}, + {file = "google_crc32c-1.7.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:ed66cbe1ed9cbaaad9392b5259b3eba4a9e565420d734e6238813c428c3336c9"}, + {file = "google_crc32c-1.7.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ee6547b657621b6cbed3562ea7826c3e11cab01cd33b74e1f677690652883e77"}, + {file = "google_crc32c-1.7.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d68e17bad8f7dd9a49181a1f5a8f4b251c6dbc8cc96fb79f1d321dfd57d66f53"}, + {file = "google_crc32c-1.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:6335de12921f06e1f774d0dd1fbea6bf610abe0887a1638f64d694013138be5d"}, + {file = "google_crc32c-1.7.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:2d73a68a653c57281401871dd4aeebbb6af3191dcac751a76ce430df4d403194"}, + {file = "google_crc32c-1.7.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:22beacf83baaf59f9d3ab2bbb4db0fb018da8e5aebdce07ef9f09fce8220285e"}, + {file = "google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:19eafa0e4af11b0a4eb3974483d55d2d77ad1911e6cf6f832e1574f6781fd337"}, + {file = "google_crc32c-1.7.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6d86616faaea68101195c6bdc40c494e4d76f41e07a37ffdef270879c15fb65"}, + {file = "google_crc32c-1.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:b7491bdc0c7564fcf48c0179d2048ab2f7c7ba36b84ccd3a3e1c3f7a72d3bba6"}, + {file = "google_crc32c-1.7.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:df8b38bdaf1629d62d51be8bdd04888f37c451564c2042d36e5812da9eff3c35"}, + {file = "google_crc32c-1.7.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:e42e20a83a29aa2709a0cf271c7f8aefaa23b7ab52e53b322585297bb94d4638"}, + {file = "google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:905a385140bf492ac300026717af339790921f411c0dfd9aa5a9e69a08ed32eb"}, + {file = "google_crc32c-1.7.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b211ddaf20f7ebeec5c333448582c224a7c90a9d98826fbab82c0ddc11348e6"}, + {file = "google_crc32c-1.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:0f99eaa09a9a7e642a61e06742856eec8b19fc0037832e03f941fe7cf0c8e4db"}, + {file = "google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32d1da0d74ec5634a05f53ef7df18fc646666a25efaaca9fc7dcfd4caf1d98c3"}, + {file = "google_crc32c-1.7.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e10554d4abc5238823112c2ad7e4560f96c7bf3820b202660373d769d9e6e4c9"}, + {file = "google_crc32c-1.7.1-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:9fc196f0b8d8bd2789352c6a522db03f89e83a0ed6b64315923c396d7a932315"}, + {file = "google_crc32c-1.7.1-cp39-cp39-macosx_12_0_x86_64.whl", hash = "sha256:bb5e35dcd8552f76eed9461a23de1030920a3c953c1982f324be8f97946e7127"}, + {file = "google_crc32c-1.7.1-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f2226b6a8da04f1d9e61d3e357f2460b9551c5e6950071437e122c958a18ae14"}, + {file = "google_crc32c-1.7.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f2b3522222746fff0e04a9bd0a23ea003ba3cccc8cf21385c564deb1f223242"}, + {file = "google_crc32c-1.7.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3bda0fcb632d390e3ea8b6b07bf6b4f4a66c9d02dcd6fbf7ba00a197c143f582"}, + {file = "google_crc32c-1.7.1-cp39-cp39-win_amd64.whl", hash = "sha256:713121af19f1a617054c41f952294764e0c5443d5a5d9034b2cd60f5dd7e0349"}, + {file = "google_crc32c-1.7.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a8e9afc74168b0b2232fb32dd202c93e46b7d5e4bf03e66ba5dc273bb3559589"}, + {file = "google_crc32c-1.7.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa8136cc14dd27f34a3221c0f16fd42d8a40e4778273e61a3c19aedaa44daf6b"}, + {file = "google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85fef7fae11494e747c9fd1359a527e5970fc9603c90764843caabd3a16a0a48"}, + {file = "google_crc32c-1.7.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6efb97eb4369d52593ad6f75e7e10d053cf00c48983f7a973105bc70b0ac4d82"}, + {file = "google_crc32c-1.7.1.tar.gz", hash = "sha256:2bff2305f98846f3e825dbeec9ee406f89da7962accdb29356e4eadc251bd472"}, ] [package.extras] @@ -1196,6 +1833,7 @@ version = "2.7.2" description = "Utilities for Google Media Downloads and Resumable Uploads" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa"}, {file = "google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0"}, @@ -1210,21 +1848,22 @@ requests = ["requests (>=2.18.0,<3.0.0dev)"] [[package]] name = "googleapis-common-protos" -version = "1.66.0" +version = "1.70.0" description = "Common protobufs used in Google APIs" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "googleapis_common_protos-1.66.0-py2.py3-none-any.whl", hash = "sha256:d7abcd75fabb2e0ec9f74466401f6c119a0b498e27370e9be4c94cb7e382b8ed"}, - {file = "googleapis_common_protos-1.66.0.tar.gz", hash = "sha256:c3e7b33d15fdca5374cc0a7346dd92ffa847425cc4ea941d970f13680052ec8c"}, + {file = "googleapis_common_protos-1.70.0-py3-none-any.whl", hash = "sha256:b8bfcca8c25a2bb253e0e0b0adaf8c00773e5e6af6fd92397576680b807e0fd8"}, + {file = "googleapis_common_protos-1.70.0.tar.gz", hash = "sha256:0e1b44e0ea153e6594f9f394fef15193a68aaaea2d843f83e2742717ca753257"}, ] [package.dependencies] -grpcio = {version = ">=1.44.0,<2.0.0.dev0", optional = true, markers = "extra == \"grpc\""} -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0.dev0" +grpcio = {version = ">=1.44.0,<2.0.0", optional = true, markers = "extra == \"grpc\""} +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [package.extras] -grpc = ["grpcio (>=1.44.0,<2.0.0.dev0)"] +grpc = ["grpcio (>=1.44.0,<2.0.0)"] [[package]] name = "googlemaps" @@ -1232,6 +1871,7 @@ version = "4.10.0" description = "Python client library for Google Maps Platform" optional = false python-versions = ">=3.5" +groups = ["main"] files = [ {file = "googlemaps-4.10.0.tar.gz", hash = "sha256:3055fcbb1aa262a9159b589b5e6af762b10e80634ae11c59495bd44867e47d88"}, ] @@ -1241,99 +1881,100 @@ requests = ">=2.20.0,<3.0" [[package]] name = "gotrue" -version = "2.11.0" +version = "2.12.3" description = "Python Client Library for Supabase Auth" optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "gotrue-2.11.0-py3-none-any.whl", hash = "sha256:62177ffd567448b352121bc7e9244ff018d59bb746dad476b51658f856d59cf8"}, - {file = "gotrue-2.11.0.tar.gz", hash = "sha256:a0a452748ef741337820c97b934327c25f796e7cd33c0bf4341346bcc5a837f5"}, + {file = "gotrue-2.12.3-py3-none-any.whl", hash = "sha256:b1a3c6a5fe3f92e854a026c4c19de58706a96fd5fbdcc3d620b2802f6a46a26b"}, + {file = "gotrue-2.12.3.tar.gz", hash = "sha256:f874cf9d0b2f0335bfbd0d6e29e3f7aff79998cd1c14d2ad814db8c06cee3852"}, ] [package.dependencies] -httpx = {version = ">=0.26,<0.28", extras = ["http2"]} +httpx = {version = ">=0.26,<0.29", extras = ["http2"]} pydantic = ">=1.10,<3" +pyjwt = ">=2.10.1,<3.0.0" + +[[package]] +name = "gravitasml" +version = "0.1.3" +description = "" +optional = false +python-versions = "<4.0,>=3.10" +groups = ["main"] +files = [ + {file = "gravitasml-0.1.3-py3-none-any.whl", hash = "sha256:51ff98b4564b7a61f7796f18d5f2558b919d30b3722579296089645b7bc18b85"}, + {file = "gravitasml-0.1.3.tar.gz", hash = "sha256:04d240b9fa35878252d57a36032130b6516487468847fcdced1022c032a20f57"}, +] + +[package.dependencies] +black = ">=24.10.0,<25.0.0" +pydantic = ">=2.9.2,<3.0.0" +pytest = ">=8.2.1,<9.0.0" [[package]] name = "greenlet" -version = "3.1.1" +version = "3.2.3" description = "Lightweight in-process concurrent programming" optional = false -python-versions = ">=3.7" -files = [ - {file = "greenlet-3.1.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:0bbae94a29c9e5c7e4a2b7f0aae5c17e8e90acbfd3bf6270eeba60c39fce3563"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fde093fb93f35ca72a556cf72c92ea3ebfda3d79fc35bb19fbe685853869a83"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:36b89d13c49216cadb828db8dfa6ce86bbbc476a82d3a6c397f0efae0525bdd0"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94b6150a85e1b33b40b1464a3f9988dcc5251d6ed06842abff82e42632fac120"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93147c513fac16385d1036b7e5b102c7fbbdb163d556b791f0f11eada7ba65dc"}, - {file = "greenlet-3.1.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7a9bff22ce038e19bf62c4dd1ec8391062878710ded0a845bcf47cc0200617"}, - {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b2795058c23988728eec1f36a4e5e4ebad22f8320c85f3587b539b9ac84128d7"}, - {file = "greenlet-3.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ed10eac5830befbdd0c32f83e8aa6288361597550ba669b04c48f0f9a2c843c6"}, - {file = "greenlet-3.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:77c386de38a60d1dfb8e55b8c1101d68c79dfdd25c7095d51fec2dd800892b80"}, - {file = "greenlet-3.1.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e4d333e558953648ca09d64f13e6d8f0523fa705f51cae3f03b5983489958c70"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fc016b73c94e98e29af67ab7b9a879c307c6731a2c9da0db5a7d9b7edd1159"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5e975ca70269d66d17dd995dafc06f1b06e8cb1ec1e9ed54c1d1e4a7c4cf26e"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b2813dc3de8c1ee3f924e4d4227999285fd335d1bcc0d2be6dc3f1f6a318ec1"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e347b3bfcf985a05e8c0b7d462ba6f15b1ee1c909e2dcad795e49e91b152c383"}, - {file = "greenlet-3.1.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e8f8c9cb53cdac7ba9793c276acd90168f416b9ce36799b9b885790f8ad6c0a"}, - {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:62ee94988d6b4722ce0028644418d93a52429e977d742ca2ccbe1c4f4a792511"}, - {file = "greenlet-3.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:1776fd7f989fc6b8d8c8cb8da1f6b82c5814957264d1f6cf818d475ec2bf6395"}, - {file = "greenlet-3.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:48ca08c771c268a768087b408658e216133aecd835c0ded47ce955381105ba39"}, - {file = "greenlet-3.1.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:4afe7ea89de619adc868e087b4d2359282058479d7cfb94970adf4b55284574d"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f406b22b7c9a9b4f8aa9d2ab13d6ae0ac3e85c9a809bd590ad53fed2bf70dc79"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c3a701fe5a9695b238503ce5bbe8218e03c3bcccf7e204e455e7462d770268aa"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2846930c65b47d70b9d178e89c7e1a69c95c1f68ea5aa0a58646b7a96df12441"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99cfaa2110534e2cf3ba31a7abcac9d328d1d9f1b95beede58294a60348fba36"}, - {file = "greenlet-3.1.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1443279c19fca463fc33e65ef2a935a5b09bb90f978beab37729e1c3c6c25fe9"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b7cede291382a78f7bb5f04a529cb18e068dd29e0fb27376074b6d0317bf4dd0"}, - {file = "greenlet-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:23f20bb60ae298d7d8656c6ec6db134bca379ecefadb0b19ce6f19d1f232a942"}, - {file = "greenlet-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:7124e16b4c55d417577c2077be379514321916d5790fa287c9ed6f23bd2ffd01"}, - {file = "greenlet-3.1.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:05175c27cb459dcfc05d026c4232f9de8913ed006d42713cb8a5137bd49375f1"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:935e943ec47c4afab8965954bf49bfa639c05d4ccf9ef6e924188f762145c0ff"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:667a9706c970cb552ede35aee17339a18e8f2a87a51fba2ed39ceeeb1004798a"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8a678974d1f3aa55f6cc34dc480169d58f2e6d8958895d68845fa4ab566509e"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efc0f674aa41b92da8c49e0346318c6075d734994c3c4e4430b1c3f853e498e4"}, - {file = "greenlet-3.1.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0153404a4bb921f0ff1abeb5ce8a5131da56b953eda6e14b88dc6bbc04d2049e"}, - {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:275f72decf9932639c1c6dd1013a1bc266438eb32710016a1c742df5da6e60a1"}, - {file = "greenlet-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c4aab7f6381f38a4b42f269057aee279ab0fc7bf2e929e3d4abfae97b682a12c"}, - {file = "greenlet-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:b42703b1cf69f2aa1df7d1030b9d77d3e584a70755674d60e710f0af570f3761"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1695e76146579f8c06c1509c7ce4dfe0706f49c6831a817ac04eebb2fd02011"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7876452af029456b3f3549b696bb36a06db7c90747740c5302f74a9e9fa14b13"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4ead44c85f8ab905852d3de8d86f6f8baf77109f9da589cb4fa142bd3b57b475"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8320f64b777d00dd7ccdade271eaf0cad6636343293a25074cc5566160e4de7b"}, - {file = "greenlet-3.1.1-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6510bf84a6b643dabba74d3049ead221257603a253d0a9873f55f6a59a65f822"}, - {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:04b013dc07c96f83134b1e99888e7a79979f1a247e2a9f59697fa14b5862ed01"}, - {file = "greenlet-3.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:411f015496fec93c1c8cd4e5238da364e1da7a124bcb293f085bf2860c32c6f6"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:47da355d8687fd65240c364c90a31569a133b7b60de111c255ef5b606f2ae291"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98884ecf2ffb7d7fe6bd517e8eb99d31ff7855a840fa6d0d63cd07c037f6a981"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1d4aeb8891338e60d1ab6127af1fe45def5259def8094b9c7e34690c8858803"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db32b5348615a04b82240cc67983cb315309e88d444a288934ee6ceaebcad6cc"}, - {file = "greenlet-3.1.1-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcc62f31eae24de7f8dce72134c8651c58000d3b1868e01392baea7c32c247de"}, - {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1d3755bcb2e02de341c55b4fca7a745a24a9e7212ac953f6b3a48d117d7257aa"}, - {file = "greenlet-3.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:b8da394b34370874b4572676f36acabac172602abf054cbc4ac910219f3340af"}, - {file = "greenlet-3.1.1-cp37-cp37m-win32.whl", hash = "sha256:a0dfc6c143b519113354e780a50381508139b07d2177cb6ad6a08278ec655798"}, - {file = "greenlet-3.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:54558ea205654b50c438029505def3834e80f0869a70fb15b871c29b4575ddef"}, - {file = "greenlet-3.1.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:346bed03fe47414091be4ad44786d1bd8bef0c3fcad6ed3dee074a032ab408a9"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dfc59d69fc48664bc693842bd57acfdd490acafda1ab52c7836e3fc75c90a111"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21e10da6ec19b457b82636209cbe2331ff4306b54d06fa04b7c138ba18c8a81"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:37b9de5a96111fc15418819ab4c4432e4f3c2ede61e660b1e33971eba26ef9ba"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ef9ea3f137e5711f0dbe5f9263e8c009b7069d8a1acea822bd5e9dae0ae49c8"}, - {file = "greenlet-3.1.1-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:85f3ff71e2e60bd4b4932a043fbbe0f499e263c628390b285cb599154a3b03b1"}, - {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:95ffcf719966dd7c453f908e208e14cde192e09fde6c7186c8f1896ef778d8cd"}, - {file = "greenlet-3.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:03a088b9de532cbfe2ba2034b2b85e82df37874681e8c470d6fb2f8c04d7e4b7"}, - {file = "greenlet-3.1.1-cp38-cp38-win32.whl", hash = "sha256:8b8b36671f10ba80e159378df9c4f15c14098c4fd73a36b9ad715f057272fbef"}, - {file = "greenlet-3.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:7017b2be767b9d43cc31416aba48aab0d2309ee31b4dbf10a1d38fb7972bdf9d"}, - {file = "greenlet-3.1.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:396979749bd95f018296af156201d6211240e7a23090f50a8d5d18c370084dc3"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca9d0ff5ad43e785350894d97e13633a66e2b50000e8a183a50a88d834752d42"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6ff3b14f2df4c41660a7dec01045a045653998784bf8cfcb5a525bdffffbc8f"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94ebba31df2aa506d7b14866fed00ac141a867e63143fe5bca82a8e503b36437"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73aaad12ac0ff500f62cebed98d8789198ea0e6f233421059fa68a5aa7220145"}, - {file = "greenlet-3.1.1-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63e4844797b975b9af3a3fb8f7866ff08775f5426925e1e0bbcfe7932059a12c"}, - {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7939aa3ca7d2a1593596e7ac6d59391ff30281ef280d8632fa03d81f7c5f955e"}, - {file = "greenlet-3.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:d0028e725ee18175c6e422797c407874da24381ce0690d6b9396c204c7f7276e"}, - {file = "greenlet-3.1.1-cp39-cp39-win32.whl", hash = "sha256:5e06afd14cbaf9e00899fae69b24a32f2196c19de08fcb9f4779dd4f004e5e7c"}, - {file = "greenlet-3.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:3319aa75e0e0639bc15ff54ca327e8dc7a6fe404003496e3c6925cd3142e0e22"}, - {file = "greenlet-3.1.1.tar.gz", hash = "sha256:4ce3ac6cdb6adf7946475d7ef31777c26d94bccc377e070a7986bd2d5c515467"}, +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "greenlet-3.2.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:1afd685acd5597349ee6d7a88a8bec83ce13c106ac78c196ee9dde7c04fe87be"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:761917cac215c61e9dc7324b2606107b3b292a8349bdebb31503ab4de3f559ac"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:a433dbc54e4a37e4fff90ef34f25a8c00aed99b06856f0119dcf09fbafa16392"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:72e77ed69312bab0434d7292316d5afd6896192ac4327d44f3d613ecb85b037c"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:68671180e3849b963649254a882cd544a3c75bfcd2c527346ad8bb53494444db"}, + {file = "greenlet-3.2.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49c8cfb18fb419b3d08e011228ef8a25882397f3a859b9fe1436946140b6756b"}, + {file = "greenlet-3.2.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:efc6dc8a792243c31f2f5674b670b3a95d46fa1c6a912b8e310d6f542e7b0712"}, + {file = "greenlet-3.2.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:731e154aba8e757aedd0781d4b240f1225b075b4409f1bb83b05ff410582cf00"}, + {file = "greenlet-3.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:96c20252c2f792defe9a115d3287e14811036d51e78b3aaddbee23b69b216302"}, + {file = "greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147"}, + {file = "greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5"}, + {file = "greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc"}, + {file = "greenlet-3.2.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:751261fc5ad7b6705f5f76726567375bb2104a059454e0226e1eef6c756748ba"}, + {file = "greenlet-3.2.3-cp311-cp311-win_amd64.whl", hash = "sha256:83a8761c75312361aa2b5b903b79da97f13f556164a7dd2d5448655425bd4c34"}, + {file = "greenlet-3.2.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:25ad29caed5783d4bd7a85c9251c651696164622494c00802a139c00d639242d"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88cd97bf37fe24a6710ec6a3a7799f3f81d9cd33317dcf565ff9950c83f55e0b"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:baeedccca94880d2f5666b4fa16fc20ef50ba1ee353ee2d7092b383a243b0b0d"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:be52af4b6292baecfa0f397f3edb3c6092ce071b499dd6fe292c9ac9f2c8f264"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0cc73378150b8b78b0c9fe2ce56e166695e67478550769536a6742dca3651688"}, + {file = "greenlet-3.2.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:706d016a03e78df129f68c4c9b4c4f963f7d73534e48a24f5f5a7101ed13dbbb"}, + {file = "greenlet-3.2.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:419e60f80709510c343c57b4bb5a339d8767bf9aef9b8ce43f4f143240f88b7c"}, + {file = "greenlet-3.2.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:93d48533fade144203816783373f27a97e4193177ebaaf0fc396db19e5d61163"}, + {file = "greenlet-3.2.3-cp312-cp312-win_amd64.whl", hash = "sha256:7454d37c740bb27bdeddfc3f358f26956a07d5220818ceb467a483197d84f849"}, + {file = "greenlet-3.2.3-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:500b8689aa9dd1ab26872a34084503aeddefcb438e2e7317b89b11eaea1901ad"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a07d3472c2a93117af3b0136f246b2833fdc0b542d4a9799ae5f41c28323faef"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8704b3768d2f51150626962f4b9a9e4a17d2e37c8a8d9867bbd9fa4eb938d3b3"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:5035d77a27b7c62db6cf41cf786cfe2242644a7a337a0e155c80960598baab95"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2d8aa5423cd4a396792f6d4580f88bdc6efcb9205891c9d40d20f6e670992efb"}, + {file = "greenlet-3.2.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2c724620a101f8170065d7dded3f962a2aea7a7dae133a009cada42847e04a7b"}, + {file = "greenlet-3.2.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:873abe55f134c48e1f2a6f53f7d1419192a3d1a4e873bace00499a4e45ea6af0"}, + {file = "greenlet-3.2.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:024571bbce5f2c1cfff08bf3fbaa43bbc7444f580ae13b0099e95d0e6e67ed36"}, + {file = "greenlet-3.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:5195fb1e75e592dd04ce79881c8a22becdfa3e6f500e7feb059b1e6fdd54d3e3"}, + {file = "greenlet-3.2.3-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:3d04332dddb10b4a211b68111dabaee2e1a073663d117dc10247b5b1642bac86"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8186162dffde068a465deab08fc72c767196895c39db26ab1c17c0b77a6d8b97"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f4bfbaa6096b1b7a200024784217defedf46a07c2eee1a498e94a1b5f8ec5728"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:ed6cfa9200484d234d8394c70f5492f144b20d4533f69262d530a1a082f6ee9a"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02b0df6f63cd15012bed5401b47829cfd2e97052dc89da3cfaf2c779124eb892"}, + {file = "greenlet-3.2.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86c2d68e87107c1792e2e8d5399acec2487a4e993ab76c792408e59394d52141"}, + {file = "greenlet-3.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:8c47aae8fbbfcf82cc13327ae802ba13c9c36753b67e760023fd116bc124a62a"}, + {file = "greenlet-3.2.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:42efc522c0bd75ffa11a71e09cd8a399d83fafe36db250a87cf1dacfaa15dc64"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d760f9bdfe79bff803bad32b4d8ffb2c1d2ce906313fc10a83976ffb73d64ca7"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:8324319cbd7b35b97990090808fdc99c27fe5338f87db50514959f8059999805"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:8c37ef5b3787567d322331d5250e44e42b58c8c713859b8a04c6065f27efbf72"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ce539fb52fb774d0802175d37fcff5c723e2c7d249c65916257f0a940cee8904"}, + {file = "greenlet-3.2.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003c930e0e074db83559edc8705f3a2d066d4aa8c2f198aff1e454946efd0f26"}, + {file = "greenlet-3.2.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:7e70ea4384b81ef9e84192e8a77fb87573138aa5d4feee541d8014e452b434da"}, + {file = "greenlet-3.2.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:22eb5ba839c4b2156f18f76768233fe44b23a31decd9cc0d4cc8141c211fd1b4"}, + {file = "greenlet-3.2.3-cp39-cp39-win32.whl", hash = "sha256:4532f0d25df67f896d137431b13f4cdce89f7e3d4a96387a41290910df4d3a57"}, + {file = "greenlet-3.2.3-cp39-cp39-win_amd64.whl", hash = "sha256:aaa7aae1e7f75eaa3ae400ad98f8644bb81e1dc6ba47ce8a93d3f17274e08322"}, + {file = "greenlet-3.2.3.tar.gz", hash = "sha256:8b0dd8ae4c0d6f5e54ee55ba935eeb3d735a9b58a8a1e5b5cbab64e01a39f365"}, ] [package.extras] @@ -1342,13 +1983,14 @@ test = ["objgraph", "psutil"] [[package]] name = "groq" -version = "0.13.1" +version = "0.30.0" description = "The official Python library for the groq API" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "groq-0.13.1-py3-none-any.whl", hash = "sha256:0c5d1d6df93de55de705fe73729b79baaa0c871f7575d6aa64b2962b56101b3e"}, - {file = "groq-0.13.1.tar.gz", hash = "sha256:588fd5bee984f4eb46ec89552778d5698b9e9614435defef868645c19463cbcc"}, + {file = "groq-0.30.0-py3-none-any.whl", hash = "sha256:6d9609a7778ba56432f45c1bac21b005f02c6c0aca9c1c094e65536f162c1e83"}, + {file = "groq-0.30.0.tar.gz", hash = "sha256:919466e48fcbebef08fed3f71debb0f96b0ea8d2ec77842c384aa843019f6e2c"}, ] [package.dependencies] @@ -1359,156 +2001,195 @@ pydantic = ">=1.9.0,<3" sniffio = "*" typing-extensions = ">=4.10,<5" +[package.extras] +aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.6)"] + [[package]] name = "grpc-google-iam-v1" -version = "0.13.1" +version = "0.14.2" description = "IAM API client library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "grpc-google-iam-v1-0.13.1.tar.gz", hash = "sha256:3ff4b2fd9d990965e410965253c0da6f66205d5a8291c4c31c6ebecca18a9001"}, - {file = "grpc_google_iam_v1-0.13.1-py2.py3-none-any.whl", hash = "sha256:c3e86151a981811f30d5e7330f271cee53e73bb87755e88cc3b6f0c7b5fe374e"}, + {file = "grpc_google_iam_v1-0.14.2-py3-none-any.whl", hash = "sha256:a3171468459770907926d56a440b2bb643eec1d7ba215f48f3ecece42b4d8351"}, + {file = "grpc_google_iam_v1-0.14.2.tar.gz", hash = "sha256:b3e1fc387a1a329e41672197d0ace9de22c78dd7d215048c4c78712073f7bd20"}, ] [package.dependencies] -googleapis-common-protos = {version = ">=1.56.0,<2.0.0dev", extras = ["grpc"]} -grpcio = ">=1.44.0,<2.0.0dev" -protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<6.0.0dev" +googleapis-common-protos = {version = ">=1.56.0,<2.0.0", extras = ["grpc"]} +grpcio = ">=1.44.0,<2.0.0" +protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4.21.4 || >4.21.4,<4.21.5 || >4.21.5,<7.0.0" [[package]] name = "grpcio" -version = "1.68.1" +version = "1.73.1" description = "HTTP/2-based RPC framework" optional = false -python-versions = ">=3.8" -files = [ - {file = "grpcio-1.68.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:d35740e3f45f60f3c37b1e6f2f4702c23867b9ce21c6410254c9c682237da68d"}, - {file = "grpcio-1.68.1-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:d99abcd61760ebb34bdff37e5a3ba333c5cc09feda8c1ad42547bea0416ada78"}, - {file = "grpcio-1.68.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:f8261fa2a5f679abeb2a0a93ad056d765cdca1c47745eda3f2d87f874ff4b8c9"}, - {file = "grpcio-1.68.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0feb02205a27caca128627bd1df4ee7212db051019a9afa76f4bb6a1a80ca95e"}, - {file = "grpcio-1.68.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:919d7f18f63bcad3a0f81146188e90274fde800a94e35d42ffe9eadf6a9a6330"}, - {file = "grpcio-1.68.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:963cc8d7d79b12c56008aabd8b457f400952dbea8997dd185f155e2f228db079"}, - {file = "grpcio-1.68.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:ccf2ebd2de2d6661e2520dae293298a3803a98ebfc099275f113ce1f6c2a80f1"}, - {file = "grpcio-1.68.1-cp310-cp310-win32.whl", hash = "sha256:2cc1fd04af8399971bcd4f43bd98c22d01029ea2e56e69c34daf2bf8470e47f5"}, - {file = "grpcio-1.68.1-cp310-cp310-win_amd64.whl", hash = "sha256:ee2e743e51cb964b4975de572aa8fb95b633f496f9fcb5e257893df3be854746"}, - {file = "grpcio-1.68.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:55857c71641064f01ff0541a1776bfe04a59db5558e82897d35a7793e525774c"}, - {file = "grpcio-1.68.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4b177f5547f1b995826ef529d2eef89cca2f830dd8b2c99ffd5fde4da734ba73"}, - {file = "grpcio-1.68.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:3522c77d7e6606d6665ec8d50e867f13f946a4e00c7df46768f1c85089eae515"}, - {file = "grpcio-1.68.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9d1fae6bbf0816415b81db1e82fb3bf56f7857273c84dcbe68cbe046e58e1ccd"}, - {file = "grpcio-1.68.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:298ee7f80e26f9483f0b6f94cc0a046caf54400a11b644713bb5b3d8eb387600"}, - {file = "grpcio-1.68.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cbb5780e2e740b6b4f2d208e90453591036ff80c02cc605fea1af8e6fc6b1bbe"}, - {file = "grpcio-1.68.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:ddda1aa22495d8acd9dfbafff2866438d12faec4d024ebc2e656784d96328ad0"}, - {file = "grpcio-1.68.1-cp311-cp311-win32.whl", hash = "sha256:b33bd114fa5a83f03ec6b7b262ef9f5cac549d4126f1dc702078767b10c46ed9"}, - {file = "grpcio-1.68.1-cp311-cp311-win_amd64.whl", hash = "sha256:7f20ebec257af55694d8f993e162ddf0d36bd82d4e57f74b31c67b3c6d63d8b2"}, - {file = "grpcio-1.68.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8829924fffb25386995a31998ccbbeaa7367223e647e0122043dfc485a87c666"}, - {file = "grpcio-1.68.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:3aed6544e4d523cd6b3119b0916cef3d15ef2da51e088211e4d1eb91a6c7f4f1"}, - {file = "grpcio-1.68.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:4efac5481c696d5cb124ff1c119a78bddbfdd13fc499e3bc0ca81e95fc573684"}, - {file = "grpcio-1.68.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ab2d912ca39c51f46baf2a0d92aa265aa96b2443266fc50d234fa88bf877d8e"}, - {file = "grpcio-1.68.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95c87ce2a97434dffe7327a4071839ab8e8bffd0054cc74cbe971fba98aedd60"}, - {file = "grpcio-1.68.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:e4842e4872ae4ae0f5497bf60a0498fa778c192cc7a9e87877abd2814aca9475"}, - {file = "grpcio-1.68.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:255b1635b0ed81e9f91da4fcc8d43b7ea5520090b9a9ad9340d147066d1d3613"}, - {file = "grpcio-1.68.1-cp312-cp312-win32.whl", hash = "sha256:7dfc914cc31c906297b30463dde0b9be48e36939575eaf2a0a22a8096e69afe5"}, - {file = "grpcio-1.68.1-cp312-cp312-win_amd64.whl", hash = "sha256:a0c8ddabef9c8f41617f213e527254c41e8b96ea9d387c632af878d05db9229c"}, - {file = "grpcio-1.68.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:a47faedc9ea2e7a3b6569795c040aae5895a19dde0c728a48d3c5d7995fda385"}, - {file = "grpcio-1.68.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:390eee4225a661c5cd133c09f5da1ee3c84498dc265fd292a6912b65c421c78c"}, - {file = "grpcio-1.68.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:66a24f3d45c33550703f0abb8b656515b0ab777970fa275693a2f6dc8e35f1c1"}, - {file = "grpcio-1.68.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c08079b4934b0bf0a8847f42c197b1d12cba6495a3d43febd7e99ecd1cdc8d54"}, - {file = "grpcio-1.68.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8720c25cd9ac25dd04ee02b69256d0ce35bf8a0f29e20577427355272230965a"}, - {file = "grpcio-1.68.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:04cfd68bf4f38f5bb959ee2361a7546916bd9a50f78617a346b3aeb2b42e2161"}, - {file = "grpcio-1.68.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:c28848761a6520c5c6071d2904a18d339a796ebe6b800adc8b3f474c5ce3c3ad"}, - {file = "grpcio-1.68.1-cp313-cp313-win32.whl", hash = "sha256:77d65165fc35cff6e954e7fd4229e05ec76102d4406d4576528d3a3635fc6172"}, - {file = "grpcio-1.68.1-cp313-cp313-win_amd64.whl", hash = "sha256:a8040f85dcb9830d8bbb033ae66d272614cec6faceee88d37a88a9bd1a7a704e"}, - {file = "grpcio-1.68.1-cp38-cp38-linux_armv7l.whl", hash = "sha256:eeb38ff04ab6e5756a2aef6ad8d94e89bb4a51ef96e20f45c44ba190fa0bcaad"}, - {file = "grpcio-1.68.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:8a3869a6661ec8f81d93f4597da50336718bde9eb13267a699ac7e0a1d6d0bea"}, - {file = "grpcio-1.68.1-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:2c4cec6177bf325eb6faa6bd834d2ff6aa8bb3b29012cceb4937b86f8b74323c"}, - {file = "grpcio-1.68.1-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12941d533f3cd45d46f202e3667be8ebf6bcb3573629c7ec12c3e211d99cfccf"}, - {file = "grpcio-1.68.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80af6f1e69c5e68a2be529990684abdd31ed6622e988bf18850075c81bb1ad6e"}, - {file = "grpcio-1.68.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:e8dbe3e00771bfe3d04feed8210fc6617006d06d9a2679b74605b9fed3e8362c"}, - {file = "grpcio-1.68.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:83bbf5807dc3ee94ce1de2dfe8a356e1d74101e4b9d7aa8c720cc4818a34aded"}, - {file = "grpcio-1.68.1-cp38-cp38-win32.whl", hash = "sha256:8cb620037a2fd9eeee97b4531880e439ebfcd6d7d78f2e7dcc3726428ab5ef63"}, - {file = "grpcio-1.68.1-cp38-cp38-win_amd64.whl", hash = "sha256:52fbf85aa71263380d330f4fce9f013c0798242e31ede05fcee7fbe40ccfc20d"}, - {file = "grpcio-1.68.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:cb400138e73969eb5e0535d1d06cae6a6f7a15f2cc74add320e2130b8179211a"}, - {file = "grpcio-1.68.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a1b988b40f2fd9de5c820f3a701a43339d8dcf2cb2f1ca137e2c02671cc83ac1"}, - {file = "grpcio-1.68.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:96f473cdacfdd506008a5d7579c9f6a7ff245a9ade92c3c0265eb76cc591914f"}, - {file = "grpcio-1.68.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:37ea3be171f3cf3e7b7e412a98b77685eba9d4fd67421f4a34686a63a65d99f9"}, - {file = "grpcio-1.68.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ceb56c4285754e33bb3c2fa777d055e96e6932351a3082ce3559be47f8024f0"}, - {file = "grpcio-1.68.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:dffd29a2961f3263a16d73945b57cd44a8fd0b235740cb14056f0612329b345e"}, - {file = "grpcio-1.68.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:025f790c056815b3bf53da850dd70ebb849fd755a4b1ac822cb65cd631e37d43"}, - {file = "grpcio-1.68.1-cp39-cp39-win32.whl", hash = "sha256:1098f03dedc3b9810810568060dea4ac0822b4062f537b0f53aa015269be0a76"}, - {file = "grpcio-1.68.1-cp39-cp39-win_amd64.whl", hash = "sha256:334ab917792904245a028f10e803fcd5b6f36a7b2173a820c0b5b076555825e1"}, - {file = "grpcio-1.68.1.tar.gz", hash = "sha256:44a8502dd5de653ae6a73e2de50a401d84184f0331d0ac3daeb044e66d5c5054"}, +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "grpcio-1.73.1-cp310-cp310-linux_armv7l.whl", hash = "sha256:2d70f4ddd0a823436c2624640570ed6097e40935c9194482475fe8e3d9754d55"}, + {file = "grpcio-1.73.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:3841a8a5a66830261ab6a3c2a3dc539ed84e4ab019165f77b3eeb9f0ba621f26"}, + {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:628c30f8e77e0258ab788750ec92059fc3d6628590fb4b7cea8c102503623ed7"}, + {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67a0468256c9db6d5ecb1fde4bf409d016f42cef649323f0a08a72f352d1358b"}, + {file = "grpcio-1.73.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68b84d65bbdebd5926eb5c53b0b9ec3b3f83408a30e4c20c373c5337b4219ec5"}, + {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c54796ca22b8349cc594d18b01099e39f2b7ffb586ad83217655781a350ce4da"}, + {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:75fc8e543962ece2f7ecd32ada2d44c0c8570ae73ec92869f9af8b944863116d"}, + {file = "grpcio-1.73.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6a6037891cd2b1dd1406b388660522e1565ed340b1fea2955b0234bdd941a862"}, + {file = "grpcio-1.73.1-cp310-cp310-win32.whl", hash = "sha256:cce7265b9617168c2d08ae570fcc2af4eaf72e84f8c710ca657cc546115263af"}, + {file = "grpcio-1.73.1-cp310-cp310-win_amd64.whl", hash = "sha256:6a2b372e65fad38842050943f42ce8fee00c6f2e8ea4f7754ba7478d26a356ee"}, + {file = "grpcio-1.73.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:ba2cea9f7ae4bc21f42015f0ec98f69ae4179848ad744b210e7685112fa507a1"}, + {file = "grpcio-1.73.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:d74c3f4f37b79e746271aa6cdb3a1d7e4432aea38735542b23adcabaaee0c097"}, + {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:5b9b1805a7d61c9e90541cbe8dfe0a593dfc8c5c3a43fe623701b6a01b01d710"}, + {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3215f69a0670a8cfa2ab53236d9e8026bfb7ead5d4baabe7d7dc11d30fda967"}, + {file = "grpcio-1.73.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc5eccfd9577a5dc7d5612b2ba90cca4ad14c6d949216c68585fdec9848befb1"}, + {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dc7d7fd520614fce2e6455ba89791458020a39716951c7c07694f9dbae28e9c0"}, + {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:105492124828911f85127e4825d1c1234b032cb9d238567876b5515d01151379"}, + {file = "grpcio-1.73.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:610e19b04f452ba6f402ac9aa94eb3d21fbc94553368008af634812c4a85a99e"}, + {file = "grpcio-1.73.1-cp311-cp311-win32.whl", hash = "sha256:d60588ab6ba0ac753761ee0e5b30a29398306401bfbceffe7d68ebb21193f9d4"}, + {file = "grpcio-1.73.1-cp311-cp311-win_amd64.whl", hash = "sha256:6957025a4608bb0a5ff42abd75bfbb2ed99eda29d5992ef31d691ab54b753643"}, + {file = "grpcio-1.73.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:921b25618b084e75d424a9f8e6403bfeb7abef074bb6c3174701e0f2542debcf"}, + {file = "grpcio-1.73.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:277b426a0ed341e8447fbf6c1d6b68c952adddf585ea4685aa563de0f03df887"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_aarch64.whl", hash = "sha256:96c112333309493c10e118d92f04594f9055774757f5d101b39f8150f8c25582"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f48e862aed925ae987eb7084409a80985de75243389dc9d9c271dd711e589918"}, + {file = "grpcio-1.73.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83a6c2cce218e28f5040429835fa34a29319071079e3169f9543c3fbeff166d2"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:65b0458a10b100d815a8426b1442bd17001fdb77ea13665b2f7dc9e8587fdc6b"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0a9f3ea8dce9eae9d7cb36827200133a72b37a63896e0e61a9d5ec7d61a59ab1"}, + {file = "grpcio-1.73.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:de18769aea47f18e782bf6819a37c1c528914bfd5683b8782b9da356506190c8"}, + {file = "grpcio-1.73.1-cp312-cp312-win32.whl", hash = "sha256:24e06a5319e33041e322d32c62b1e728f18ab8c9dbc91729a3d9f9e3ed336642"}, + {file = "grpcio-1.73.1-cp312-cp312-win_amd64.whl", hash = "sha256:303c8135d8ab176f8038c14cc10d698ae1db9c480f2b2823f7a987aa2a4c5646"}, + {file = "grpcio-1.73.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b310824ab5092cf74750ebd8a8a8981c1810cb2b363210e70d06ef37ad80d4f9"}, + {file = "grpcio-1.73.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:8f5a6df3fba31a3485096ac85b2e34b9666ffb0590df0cd044f58694e6a1f6b5"}, + {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:052e28fe9c41357da42250a91926a3e2f74c046575c070b69659467ca5aa976b"}, + {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c0bf15f629b1497436596b1cbddddfa3234273490229ca29561209778ebe182"}, + {file = "grpcio-1.73.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ab860d5bfa788c5a021fba264802e2593688cd965d1374d31d2b1a34cacd854"}, + {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:ad1d958c31cc91ab050bd8a91355480b8e0683e21176522bacea225ce51163f2"}, + {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:f43ffb3bd415c57224c7427bfb9e6c46a0b6e998754bfa0d00f408e1873dcbb5"}, + {file = "grpcio-1.73.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:686231cdd03a8a8055f798b2b54b19428cdf18fa1549bee92249b43607c42668"}, + {file = "grpcio-1.73.1-cp313-cp313-win32.whl", hash = "sha256:89018866a096e2ce21e05eabed1567479713ebe57b1db7cbb0f1e3b896793ba4"}, + {file = "grpcio-1.73.1-cp313-cp313-win_amd64.whl", hash = "sha256:4a68f8c9966b94dff693670a5cf2b54888a48a5011c5d9ce2295a1a1465ee84f"}, + {file = "grpcio-1.73.1-cp39-cp39-linux_armv7l.whl", hash = "sha256:b4adc97d2d7f5c660a5498bda978ebb866066ad10097265a5da0511323ae9f50"}, + {file = "grpcio-1.73.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:c45a28a0cfb6ddcc7dc50a29de44ecac53d115c3388b2782404218db51cb2df3"}, + {file = "grpcio-1.73.1-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:10af9f2ab98a39f5b6c1896c6fc2036744b5b41d12739d48bed4c3e15b6cf900"}, + {file = "grpcio-1.73.1-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:45cf17dcce5ebdb7b4fe9e86cb338fa99d7d1bb71defc78228e1ddf8d0de8cbb"}, + {file = "grpcio-1.73.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c502c2e950fc7e8bf05c047e8a14522ef7babac59abbfde6dbf46b7a0d9c71e"}, + {file = "grpcio-1.73.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6abfc0f9153dc4924536f40336f88bd4fe7bd7494f028675e2e04291b8c2c62a"}, + {file = "grpcio-1.73.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ed451a0e39c8e51eb1612b78686839efd1a920666d1666c1adfdb4fd51680c0f"}, + {file = "grpcio-1.73.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:07f08705a5505c9b5b0cbcbabafb96462b5a15b7236bbf6bbcc6b0b91e1cbd7e"}, + {file = "grpcio-1.73.1-cp39-cp39-win32.whl", hash = "sha256:ad5c958cc3d98bb9d71714dc69f1c13aaf2f4b53e29d4cc3f1501ef2e4d129b2"}, + {file = "grpcio-1.73.1-cp39-cp39-win_amd64.whl", hash = "sha256:42f0660bce31b745eb9d23f094a332d31f210dcadd0fc8e5be7e4c62a87ce86b"}, + {file = "grpcio-1.73.1.tar.gz", hash = "sha256:7fce2cd1c0c1116cf3850564ebfc3264fba75d3c74a7414373f1238ea365ef87"}, ] [package.extras] -protobuf = ["grpcio-tools (>=1.68.1)"] +protobuf = ["grpcio-tools (>=1.73.1)"] [[package]] name = "grpcio-status" -version = "1.68.1" +version = "1.71.2" description = "Status proto mapping for gRPC" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "grpcio_status-1.68.1-py3-none-any.whl", hash = "sha256:66f3d8847f665acfd56221333d66f7ad8927903d87242a482996bdb45e8d28fd"}, - {file = "grpcio_status-1.68.1.tar.gz", hash = "sha256:e1378d036c81a1610d7b4c7a146cd663dd13fcc915cf4d7d053929dba5bbb6e1"}, + {file = "grpcio_status-1.71.2-py3-none-any.whl", hash = "sha256:803c98cb6a8b7dc6dbb785b1111aed739f241ab5e9da0bba96888aa74704cfd3"}, + {file = "grpcio_status-1.71.2.tar.gz", hash = "sha256:c7a97e176df71cdc2c179cd1847d7fc86cca5832ad12e9798d7fed6b7a1aab50"}, ] [package.dependencies] googleapis-common-protos = ">=1.5.5" -grpcio = ">=1.68.1" +grpcio = ">=1.71.2" protobuf = ">=5.26.1,<6.0dev" [[package]] name = "h11" -version = "0.14.0" +version = "0.16.0" description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" +groups = ["main", "dev"] files = [ - {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, - {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] [[package]] name = "h2" -version = "4.1.0" -description = "HTTP/2 State-Machine based protocol implementation" +version = "4.2.0" +description = "Pure-Python HTTP/2 protocol implementation" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "h2-4.1.0-py3-none-any.whl", hash = "sha256:03a46bcf682256c95b5fd9e9a99c1323584c3eec6440d379b9903d709476bc6d"}, - {file = "h2-4.1.0.tar.gz", hash = "sha256:a83aca08fbe7aacb79fec788c9c0bac936343560ed9ec18b82a13a12c28d2abb"}, + {file = "h2-4.2.0-py3-none-any.whl", hash = "sha256:479a53ad425bb29af087f3458a61d30780bc818e4ebcf01f0b536ba916462ed0"}, + {file = "h2-4.2.0.tar.gz", hash = "sha256:c8a52129695e88b1a0578d8d2cc6842bbd79128ac685463b887ee278126ad01f"}, ] [package.dependencies] -hpack = ">=4.0,<5" -hyperframe = ">=6.0,<7" +hpack = ">=4.1,<5" +hyperframe = ">=6.1,<7" + +[[package]] +name = "hf-xet" +version = "1.1.8" +description = "Fast transfer of large files with the Hugging Face Hub." +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\"" +files = [ + {file = "hf_xet-1.1.8-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:3d5f82e533fc51c7daad0f9b655d9c7811b5308e5890236828bd1dd3ed8fea74"}, + {file = "hf_xet-1.1.8-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:8e2dba5896bca3ab61d0bef4f01a1647004de59640701b37e37eaa57087bbd9d"}, + {file = "hf_xet-1.1.8-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfe5700bc729be3d33d4e9a9b5cc17a951bf8c7ada7ba0c9198a6ab2053b7453"}, + {file = "hf_xet-1.1.8-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:09e86514c3c4284ed8a57d6b0f3d089f9836a0af0a1ceb3c9dd664f1f3eaefef"}, + {file = "hf_xet-1.1.8-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4a9b99ab721d385b83f4fc8ee4e0366b0b59dce03b5888a86029cc0ca634efbf"}, + {file = "hf_xet-1.1.8-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25b9d43333bbef39aeae1616789ec329c21401a7fe30969d538791076227b591"}, + {file = "hf_xet-1.1.8-cp37-abi3-win_amd64.whl", hash = "sha256:4171f31d87b13da4af1ed86c98cf763292e4720c088b4957cf9d564f92904ca9"}, + {file = "hf_xet-1.1.8.tar.gz", hash = "sha256:62a0043e441753bbc446dcb5a3fe40a4d03f5fb9f13589ef1df9ab19252beb53"}, +] + +[package.extras] +tests = ["pytest"] [[package]] name = "hpack" -version = "4.0.0" -description = "Pure-Python HPACK header compression" +version = "4.1.0" +description = "Pure-Python HPACK header encoding" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "hpack-4.0.0-py3-none-any.whl", hash = "sha256:84a076fad3dc9a9f8063ccb8041ef100867b1878b25ef0ee63847a5d53818a6c"}, - {file = "hpack-4.0.0.tar.gz", hash = "sha256:fc41de0c63e687ebffde81187a948221294896f6bdc0ae2312708df339430095"}, + {file = "hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496"}, + {file = "hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca"}, +] + +[[package]] +name = "html2text" +version = "2024.2.26" +description = "Turn HTML into equivalent Markdown-structured text." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "html2text-2024.2.26.tar.gz", hash = "sha256:05f8e367d15aaabc96415376776cdd11afd5127a77fce6e36afc60c563ca2c32"}, ] [[package]] name = "httpcore" -version = "1.0.7" +version = "1.0.9" description = "A minimal low-level HTTP client." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ - {file = "httpcore-1.0.7-py3-none-any.whl", hash = "sha256:a3fff8f43dc260d5bd363d9f9cf1830fa3a458b332856f34282de498ed420edd"}, - {file = "httpcore-1.0.7.tar.gz", hash = "sha256:8551cb62a169ec7162ac7be8d4817d561f60e08eaa485234898414bb5a8a0b4c"}, + {file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"}, + {file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"}, ] [package.dependencies] certifi = "*" -h11 = ">=0.13,<0.15" +h11 = ">=0.16" [package.extras] asyncio = ["anyio (>=4.0,<5.0)"] @@ -1522,6 +2203,7 @@ version = "0.22.0" description = "A comprehensive HTTP client library." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] files = [ {file = "httplib2-0.22.0-py3-none-any.whl", hash = "sha256:14ae0a53c1ba8f3d37e9e27cf37eabb0fb9980f435ba405d546948b009dd64dc"}, {file = "httplib2-0.22.0.tar.gz", hash = "sha256:d7a10bc5ef5ab08322488bde8c726eeee5c8618723fdb399597ec58f3d82df81"}, @@ -1536,6 +2218,7 @@ version = "0.6.4" description = "A collection of framework independent HTTP protocol utils." optional = false python-versions = ">=3.8.0" +groups = ["main"] files = [ {file = "httptools-0.6.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3c73ce323711a6ffb0d247dcd5a550b8babf0f757e86a52558fe5b86d6fefcc0"}, {file = "httptools-0.6.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:345c288418f0944a6fe67be8e6afa9262b18c7626c3ef3c28adc5eabc06a68da"}, @@ -1587,13 +2270,14 @@ test = ["Cython (>=0.29.24)"] [[package]] name = "httpx" -version = "0.27.2" +version = "0.28.1" description = "The next generation HTTP client." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ - {file = "httpx-0.27.2-py3-none-any.whl", hash = "sha256:7bb2708e112d8fdd7829cd4243970f0c223274051cb35ee80c03301ee29a3df0"}, - {file = "httpx-0.27.2.tar.gz", hash = "sha256:f7c2be1d2f3c3c3160d441802406b206c2b76f5947b11115e6df10c6c65e66c2"}, + {file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"}, + {file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"}, ] [package.dependencies] @@ -1602,32 +2286,87 @@ certifi = "*" h2 = {version = ">=3,<5", optional = true, markers = "extra == \"http2\""} httpcore = "==1.*" idna = "*" -sniffio = "*" [package.extras] -brotli = ["brotli", "brotlicffi"] +brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""] cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"] http2 = ["h2 (>=3,<5)"] socks = ["socksio (==1.*)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "huggingface-hub" +version = "0.34.4" +description = "Client library to download and publish models, datasets and other repos on the huggingface.co hub" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "huggingface_hub-0.34.4-py3-none-any.whl", hash = "sha256:9b365d781739c93ff90c359844221beef048403f1bc1f1c123c191257c3c890a"}, + {file = "huggingface_hub-0.34.4.tar.gz", hash = "sha256:a4228daa6fb001be3f4f4bdaf9a0db00e1739235702848df00885c9b5742c85c"}, +] + +[package.dependencies] +filelock = "*" +fsspec = ">=2023.5.0" +hf-xet = {version = ">=1.1.3,<2.0.0", markers = "platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"arm64\" or platform_machine == \"aarch64\""} +packaging = ">=20.9" +pyyaml = ">=5.1" +requests = "*" +tqdm = ">=4.42.1" +typing-extensions = ">=3.7.4.3" + +[package.extras] +all = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +cli = ["InquirerPy (==0.3.4)"] +dev = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "ruff (>=0.9.0)", "soundfile", "types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)", "urllib3 (<2.0)"] +fastai = ["fastai (>=2.4)", "fastcore (>=1.3.27)", "toml"] +hf-transfer = ["hf-transfer (>=0.1.4)"] +hf-xet = ["hf-xet (>=1.1.2,<2.0.0)"] +inference = ["aiohttp"] +mcp = ["aiohttp", "mcp (>=1.8.0)", "typer"] +oauth = ["authlib (>=1.3.2)", "fastapi", "httpx", "itsdangerous"] +quality = ["libcst (>=1.4.0)", "mypy (==1.15.0) ; python_version >= \"3.9\"", "mypy (>=1.14.1,<1.15.0) ; python_version == \"3.8\"", "ruff (>=0.9.0)"] +tensorflow = ["graphviz", "pydot", "tensorflow"] +tensorflow-testing = ["keras (<3.0)", "tensorflow"] +testing = ["InquirerPy (==0.3.4)", "Jinja2", "Pillow", "aiohttp", "authlib (>=1.3.2)", "fastapi", "gradio (>=4.0.0)", "httpx", "itsdangerous", "jedi", "numpy", "pytest (>=8.1.1,<8.2.2)", "pytest-asyncio", "pytest-cov", "pytest-env", "pytest-mock", "pytest-rerunfailures", "pytest-vcr", "pytest-xdist", "soundfile", "urllib3 (<2.0)"] +torch = ["safetensors[torch]", "torch"] +typing = ["types-PyYAML", "types-requests", "types-simplejson", "types-toml", "types-tqdm", "types-urllib3", "typing-extensions (>=4.8.0)"] + [[package]] name = "hyperframe" -version = "6.0.1" -description = "HTTP/2 framing layer for Python" +version = "6.1.0" +description = "Pure-Python HTTP/2 framing" optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "hyperframe-6.0.1-py3-none-any.whl", hash = "sha256:0ec6bafd80d8ad2195c4f03aacba3a8265e57bc4cff261e802bf39970ed02a15"}, - {file = "hyperframe-6.0.1.tar.gz", hash = "sha256:ae510046231dc8e9ecb1a6586f63d2347bf4c8905914aa84ba585ae85f28a914"}, + {file = "hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5"}, + {file = "hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08"}, ] +[[package]] +name = "identify" +version = "2.6.12" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2"}, + {file = "identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6"}, +] + +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" version = "3.10" description = "Internationalized Domain Names in Applications (IDNA)" optional = false python-versions = ">=3.6" +groups = ["main", "dev"] files = [ {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, @@ -1636,38 +2375,103 @@ files = [ [package.extras] all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] +[[package]] +name = "imageio" +version = "2.37.0" +description = "Library for reading and writing a wide range of image, video, scientific, and volumetric data formats." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "imageio-2.37.0-py3-none-any.whl", hash = "sha256:11efa15b87bc7871b61590326b2d635439acc321cf7f8ce996f812543ce10eed"}, + {file = "imageio-2.37.0.tar.gz", hash = "sha256:71b57b3669666272c818497aebba2b4c5f20d5b37c81720e5e1a56d59c492996"}, +] + +[package.dependencies] +numpy = "*" +pillow = ">=8.3.2" + +[package.extras] +all-plugins = ["astropy", "av", "imageio-ffmpeg", "numpy (>2)", "pillow-heif", "psutil", "rawpy", "tifffile"] +all-plugins-pypy = ["av", "imageio-ffmpeg", "pillow-heif", "psutil", "tifffile"] +build = ["wheel"] +dev = ["black", "flake8", "fsspec[github]", "pytest", "pytest-cov"] +docs = ["numpydoc", "pydata-sphinx-theme", "sphinx (<6)"] +ffmpeg = ["imageio-ffmpeg", "psutil"] +fits = ["astropy"] +full = ["astropy", "av", "black", "flake8", "fsspec[github]", "gdal", "imageio-ffmpeg", "itk", "numpy (>2)", "numpydoc", "pillow-heif", "psutil", "pydata-sphinx-theme", "pytest", "pytest-cov", "rawpy", "sphinx (<6)", "tifffile", "wheel"] +gdal = ["gdal"] +itk = ["itk"] +linting = ["black", "flake8"] +pillow-heif = ["pillow-heif"] +pyav = ["av"] +rawpy = ["numpy (>2)", "rawpy"] +test = ["fsspec[github]", "pytest", "pytest-cov"] +tifffile = ["tifffile"] + +[[package]] +name = "imageio-ffmpeg" +version = "0.6.0" +description = "FFMPEG wrapper for Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2"}, + {file = "imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a"}, + {file = "imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755"}, +] + [[package]] name = "importlib-metadata" -version = "8.5.0" +version = "8.7.0" description = "Read metadata from Python packages" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, + {file = "importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd"}, + {file = "importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000"}, ] [package.dependencies] zipp = ">=3.20" [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] +test = ["flufl.flake8", "importlib_resources (>=1.3) ; python_version < \"3.9\"", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] type = ["pytest-mypy"] [[package]] name = "iniconfig" -version = "2.0.0" +version = "2.1.0" description = "brain-dead simple config-ini parsing" optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "installer" +version = "0.7.0" +description = "A library for installing Python wheels." +optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, + {file = "installer-0.7.0-py3-none-any.whl", hash = "sha256:05d1933f0a5ba7d8d6296bb6d5018e7c94fa473ceb10cf198a92ccea19c27b53"}, + {file = "installer-0.7.0.tar.gz", hash = "sha256:a26d3e3116289bb08216e0d0f7d925fcef0b0194eedfa0c944bcaaa106c4b631"}, ] [[package]] @@ -1676,6 +2480,7 @@ version = "5.13.2" description = "A Python utility / library to sort Python imports." optional = false python-versions = ">=3.8.0" +groups = ["dev"] files = [ {file = "isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6"}, {file = "isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109"}, @@ -1684,15 +2489,94 @@ files = [ [package.extras] colors = ["colorama (>=0.4.6)"] +[[package]] +name = "jaraco-classes" +version = "3.4.0" +description = "Utility functions for Python class constructs" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790"}, + {file = "jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd"}, +] + +[package.dependencies] +more-itertools = "*" + +[package.extras] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1)"] + +[[package]] +name = "jaraco-context" +version = "6.0.1" +description = "Useful decorators and context managers" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "jaraco.context-6.0.1-py3-none-any.whl", hash = "sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4"}, + {file = "jaraco_context-6.0.1.tar.gz", hash = "sha256:9bae4ea555cf0b14938dc0aee7c9f32ed303aa20a3b73e7dc80111628792d1b3"}, +] + +[package.dependencies] +"backports.tarfile" = {version = "*", markers = "python_version < \"3.12\""} + +[package.extras] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +test = ["portend", "pytest (>=6,!=8.1.*)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] + +[[package]] +name = "jaraco-functools" +version = "4.2.1" +description = "Functools like those found in stdlib" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jaraco_functools-4.2.1-py3-none-any.whl", hash = "sha256:590486285803805f4b1f99c60ca9e94ed348d4added84b74c7a12885561e524e"}, + {file = "jaraco_functools-4.2.1.tar.gz", hash = "sha256:be634abfccabce56fa3053f8c7ebe37b682683a4ee7793670ced17bab0087353"}, +] + +[package.dependencies] +more_itertools = "*" + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["jaraco.classes", "pytest (>=6,!=8.1.*)"] +type = ["pytest-mypy"] + +[[package]] +name = "jeepney" +version = "0.9.0" +description = "Low-level, pure Python DBus protocol wrapper." +optional = false +python-versions = ">=3.7" +groups = ["main"] +markers = "sys_platform == \"linux\"" +files = [ + {file = "jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683"}, + {file = "jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732"}, +] + +[package.extras] +test = ["async-timeout ; python_version < \"3.11\"", "pytest", "pytest-asyncio (>=0.17)", "pytest-trio", "testpath", "trio"] +trio = ["trio"] + [[package]] name = "jinja2" -version = "3.1.4" +version = "3.1.6" description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, + {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"}, + {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"}, ] [package.dependencies] @@ -1703,87 +2587,89 @@ i18n = ["Babel (>=2.7)"] [[package]] name = "jiter" -version = "0.8.2" +version = "0.10.0" description = "Fast iterable JSON parser." optional = false -python-versions = ">=3.8" -files = [ - {file = "jiter-0.8.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ca8577f6a413abe29b079bc30f907894d7eb07a865c4df69475e868d73e71c7b"}, - {file = "jiter-0.8.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b25bd626bde7fb51534190c7e3cb97cee89ee76b76d7585580e22f34f5e3f393"}, - {file = "jiter-0.8.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5c826a221851a8dc028eb6d7d6429ba03184fa3c7e83ae01cd6d3bd1d4bd17d"}, - {file = "jiter-0.8.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d35c864c2dff13dfd79fb070fc4fc6235d7b9b359efe340e1261deb21b9fcb66"}, - {file = "jiter-0.8.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f557c55bc2b7676e74d39d19bcb8775ca295c7a028246175d6a8b431e70835e5"}, - {file = "jiter-0.8.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:580ccf358539153db147e40751a0b41688a5ceb275e6f3e93d91c9467f42b2e3"}, - {file = "jiter-0.8.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af102d3372e917cffce49b521e4c32c497515119dc7bd8a75665e90a718bbf08"}, - {file = "jiter-0.8.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cadcc978f82397d515bb2683fc0d50103acff2a180552654bb92d6045dec2c49"}, - {file = "jiter-0.8.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ba5bdf56969cad2019d4e8ffd3f879b5fdc792624129741d3d83fc832fef8c7d"}, - {file = "jiter-0.8.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3b94a33a241bee9e34b8481cdcaa3d5c2116f575e0226e421bed3f7a6ea71cff"}, - {file = "jiter-0.8.2-cp310-cp310-win32.whl", hash = "sha256:6e5337bf454abddd91bd048ce0dca5134056fc99ca0205258766db35d0a2ea43"}, - {file = "jiter-0.8.2-cp310-cp310-win_amd64.whl", hash = "sha256:4a9220497ca0cb1fe94e3f334f65b9b5102a0b8147646118f020d8ce1de70105"}, - {file = "jiter-0.8.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2dd61c5afc88a4fda7d8b2cf03ae5947c6ac7516d32b7a15bf4b49569a5c076b"}, - {file = "jiter-0.8.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a6c710d657c8d1d2adbbb5c0b0c6bfcec28fd35bd6b5f016395f9ac43e878a15"}, - {file = "jiter-0.8.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9584de0cd306072635fe4b89742bf26feae858a0683b399ad0c2509011b9dc0"}, - {file = "jiter-0.8.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5a90a923338531b7970abb063cfc087eebae6ef8ec8139762007188f6bc69a9f"}, - {file = "jiter-0.8.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d21974d246ed0181558087cd9f76e84e8321091ebfb3a93d4c341479a736f099"}, - {file = "jiter-0.8.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32475a42b2ea7b344069dc1e81445cfc00b9d0e3ca837f0523072432332e9f74"}, - {file = "jiter-0.8.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b9931fd36ee513c26b5bf08c940b0ac875de175341cbdd4fa3be109f0492586"}, - {file = "jiter-0.8.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce0820f4a3a59ddced7fce696d86a096d5cc48d32a4183483a17671a61edfddc"}, - {file = "jiter-0.8.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:8ffc86ae5e3e6a93765d49d1ab47b6075a9c978a2b3b80f0f32628f39caa0c88"}, - {file = "jiter-0.8.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5127dc1abd809431172bc3fbe8168d6b90556a30bb10acd5ded41c3cfd6f43b6"}, - {file = "jiter-0.8.2-cp311-cp311-win32.whl", hash = "sha256:66227a2c7b575720c1871c8800d3a0122bb8ee94edb43a5685aa9aceb2782d44"}, - {file = "jiter-0.8.2-cp311-cp311-win_amd64.whl", hash = "sha256:cde031d8413842a1e7501e9129b8e676e62a657f8ec8166e18a70d94d4682855"}, - {file = "jiter-0.8.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:e6ec2be506e7d6f9527dae9ff4b7f54e68ea44a0ef6b098256ddf895218a2f8f"}, - {file = "jiter-0.8.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:76e324da7b5da060287c54f2fabd3db5f76468006c811831f051942bf68c9d44"}, - {file = "jiter-0.8.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:180a8aea058f7535d1c84183c0362c710f4750bef66630c05f40c93c2b152a0f"}, - {file = "jiter-0.8.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025337859077b41548bdcbabe38698bcd93cfe10b06ff66617a48ff92c9aec60"}, - {file = "jiter-0.8.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ecff0dc14f409599bbcafa7e470c00b80f17abc14d1405d38ab02e4b42e55b57"}, - {file = "jiter-0.8.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ffd9fee7d0775ebaba131f7ca2e2d83839a62ad65e8e02fe2bd8fc975cedeb9e"}, - {file = "jiter-0.8.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14601dcac4889e0a1c75ccf6a0e4baf70dbc75041e51bcf8d0e9274519df6887"}, - {file = "jiter-0.8.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:92249669925bc1c54fcd2ec73f70f2c1d6a817928480ee1c65af5f6b81cdf12d"}, - {file = "jiter-0.8.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e725edd0929fa79f8349ab4ec7f81c714df51dc4e991539a578e5018fa4a7152"}, - {file = "jiter-0.8.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bf55846c7b7a680eebaf9c3c48d630e1bf51bdf76c68a5f654b8524335b0ad29"}, - {file = "jiter-0.8.2-cp312-cp312-win32.whl", hash = "sha256:7efe4853ecd3d6110301665a5178b9856be7e2a9485f49d91aa4d737ad2ae49e"}, - {file = "jiter-0.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:83c0efd80b29695058d0fd2fa8a556490dbce9804eac3e281f373bbc99045f6c"}, - {file = "jiter-0.8.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ca1f08b8e43dc3bd0594c992fb1fd2f7ce87f7bf0d44358198d6da8034afdf84"}, - {file = "jiter-0.8.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5672a86d55416ccd214c778efccf3266b84f87b89063b582167d803246354be4"}, - {file = "jiter-0.8.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58dc9bc9767a1101f4e5e22db1b652161a225874d66f0e5cb8e2c7d1c438b587"}, - {file = "jiter-0.8.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:37b2998606d6dadbb5ccda959a33d6a5e853252d921fec1792fc902351bb4e2c"}, - {file = "jiter-0.8.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ab9a87f3784eb0e098f84a32670cfe4a79cb6512fd8f42ae3d0709f06405d18"}, - {file = "jiter-0.8.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:79aec8172b9e3c6d05fd4b219d5de1ac616bd8da934107325a6c0d0e866a21b6"}, - {file = "jiter-0.8.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:711e408732d4e9a0208008e5892c2966b485c783cd2d9a681f3eb147cf36c7ef"}, - {file = "jiter-0.8.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:653cf462db4e8c41995e33d865965e79641ef45369d8a11f54cd30888b7e6ff1"}, - {file = "jiter-0.8.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:9c63eaef32b7bebac8ebebf4dabebdbc6769a09c127294db6babee38e9f405b9"}, - {file = "jiter-0.8.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:eb21aaa9a200d0a80dacc7a81038d2e476ffe473ffdd9c91eb745d623561de05"}, - {file = "jiter-0.8.2-cp313-cp313-win32.whl", hash = "sha256:789361ed945d8d42850f919342a8665d2dc79e7e44ca1c97cc786966a21f627a"}, - {file = "jiter-0.8.2-cp313-cp313-win_amd64.whl", hash = "sha256:ab7f43235d71e03b941c1630f4b6e3055d46b6cb8728a17663eaac9d8e83a865"}, - {file = "jiter-0.8.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b426f72cd77da3fec300ed3bc990895e2dd6b49e3bfe6c438592a3ba660e41ca"}, - {file = "jiter-0.8.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b2dd880785088ff2ad21ffee205e58a8c1ddabc63612444ae41e5e4b321b39c0"}, - {file = "jiter-0.8.2-cp313-cp313t-win_amd64.whl", hash = "sha256:3ac9f578c46f22405ff7f8b1f5848fb753cc4b8377fbec8470a7dc3997ca7566"}, - {file = "jiter-0.8.2-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:9e1fa156ee9454642adb7e7234a383884452532bc9d53d5af2d18d98ada1d79c"}, - {file = "jiter-0.8.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0cf5dfa9956d96ff2efb0f8e9c7d055904012c952539a774305aaaf3abdf3d6c"}, - {file = "jiter-0.8.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e52bf98c7e727dd44f7c4acb980cb988448faeafed8433c867888268899b298b"}, - {file = "jiter-0.8.2-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a2ecaa3c23e7a7cf86d00eda3390c232f4d533cd9ddea4b04f5d0644faf642c5"}, - {file = "jiter-0.8.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:08d4c92bf480e19fc3f2717c9ce2aa31dceaa9163839a311424b6862252c943e"}, - {file = "jiter-0.8.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99d9a1eded738299ba8e106c6779ce5c3893cffa0e32e4485d680588adae6db8"}, - {file = "jiter-0.8.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d20be8b7f606df096e08b0b1b4a3c6f0515e8dac296881fe7461dfa0fb5ec817"}, - {file = "jiter-0.8.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d33f94615fcaf872f7fd8cd98ac3b429e435c77619777e8a449d9d27e01134d1"}, - {file = "jiter-0.8.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:317b25e98a35ffec5c67efe56a4e9970852632c810d35b34ecdd70cc0e47b3b6"}, - {file = "jiter-0.8.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fc9043259ee430ecd71d178fccabd8c332a3bf1e81e50cae43cc2b28d19e4cb7"}, - {file = "jiter-0.8.2-cp38-cp38-win32.whl", hash = "sha256:fc5adda618205bd4678b146612ce44c3cbfdee9697951f2c0ffdef1f26d72b63"}, - {file = "jiter-0.8.2-cp38-cp38-win_amd64.whl", hash = "sha256:cd646c827b4f85ef4a78e4e58f4f5854fae0caf3db91b59f0d73731448a970c6"}, - {file = "jiter-0.8.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e41e75344acef3fc59ba4765df29f107f309ca9e8eace5baacabd9217e52a5ee"}, - {file = "jiter-0.8.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f22b16b35d5c1df9dfd58843ab2cd25e6bf15191f5a236bed177afade507bfc"}, - {file = "jiter-0.8.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7200b8f7619d36aa51c803fd52020a2dfbea36ffec1b5e22cab11fd34d95a6d"}, - {file = "jiter-0.8.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:70bf4c43652cc294040dbb62256c83c8718370c8b93dd93d934b9a7bf6c4f53c"}, - {file = "jiter-0.8.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9d471356dc16f84ed48768b8ee79f29514295c7295cb41e1133ec0b2b8d637d"}, - {file = "jiter-0.8.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:859e8eb3507894093d01929e12e267f83b1d5f6221099d3ec976f0c995cb6bd9"}, - {file = "jiter-0.8.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaa58399c01db555346647a907b4ef6d4f584b123943be6ed5588c3f2359c9f4"}, - {file = "jiter-0.8.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f2d5ed877f089862f4c7aacf3a542627c1496f972a34d0474ce85ee7d939c27"}, - {file = "jiter-0.8.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:03c9df035d4f8d647f8c210ddc2ae0728387275340668fb30d2421e17d9a0841"}, - {file = "jiter-0.8.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8bd2a824d08d8977bb2794ea2682f898ad3d8837932e3a74937e93d62ecbb637"}, - {file = "jiter-0.8.2-cp39-cp39-win32.whl", hash = "sha256:ca29b6371ebc40e496995c94b988a101b9fbbed48a51190a4461fcb0a68b4a36"}, - {file = "jiter-0.8.2-cp39-cp39-win_amd64.whl", hash = "sha256:1c0dfbd1be3cbefc7510102370d86e35d1d53e5a93d48519688b1bf0f761160a"}, - {file = "jiter-0.8.2.tar.gz", hash = "sha256:cd73d3e740666d0e639f678adb176fad25c1bcbdae88d8d7b857e1783bb4212d"}, +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jiter-0.10.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:cd2fb72b02478f06a900a5782de2ef47e0396b3e1f7d5aba30daeb1fce66f303"}, + {file = "jiter-0.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32bb468e3af278f095d3fa5b90314728a6916d89ba3d0ffb726dd9bf7367285e"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa8b3e0068c26ddedc7abc6fac37da2d0af16b921e288a5a613f4b86f050354f"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:286299b74cc49e25cd42eea19b72aa82c515d2f2ee12d11392c56d8701f52224"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6ed5649ceeaeffc28d87fb012d25a4cd356dcd53eff5acff1f0466b831dda2a7"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2ab0051160cb758a70716448908ef14ad476c3774bd03ddce075f3c1f90a3d6"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:03997d2f37f6b67d2f5c475da4412be584e1cec273c1cfc03d642c46db43f8cf"}, + {file = "jiter-0.10.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c404a99352d839fed80d6afd6c1d66071f3bacaaa5c4268983fc10f769112e90"}, + {file = "jiter-0.10.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:66e989410b6666d3ddb27a74c7e50d0829704ede652fd4c858e91f8d64b403d0"}, + {file = "jiter-0.10.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b532d3af9ef4f6374609a3bcb5e05a1951d3bf6190dc6b176fdb277c9bbf15ee"}, + {file = "jiter-0.10.0-cp310-cp310-win32.whl", hash = "sha256:da9be20b333970e28b72edc4dff63d4fec3398e05770fb3205f7fb460eb48dd4"}, + {file = "jiter-0.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:f59e533afed0c5b0ac3eba20d2548c4a550336d8282ee69eb07b37ea526ee4e5"}, + {file = "jiter-0.10.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3bebe0c558e19902c96e99217e0b8e8b17d570906e72ed8a87170bc290b1e978"}, + {file = "jiter-0.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:558cc7e44fd8e507a236bee6a02fa17199ba752874400a0ca6cd6e2196cdb7dc"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d613e4b379a07d7c8453c5712ce7014e86c6ac93d990a0b8e7377e18505e98d"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f62cf8ba0618eda841b9bf61797f21c5ebd15a7a1e19daab76e4e4b498d515b2"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:919d139cdfa8ae8945112398511cb7fca58a77382617d279556b344867a37e61"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:13ddbc6ae311175a3b03bd8994881bc4635c923754932918e18da841632349db"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c440ea003ad10927a30521a9062ce10b5479592e8a70da27f21eeb457b4a9c5"}, + {file = "jiter-0.10.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dc347c87944983481e138dea467c0551080c86b9d21de6ea9306efb12ca8f606"}, + {file = "jiter-0.10.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:13252b58c1f4d8c5b63ab103c03d909e8e1e7842d302473f482915d95fefd605"}, + {file = "jiter-0.10.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7d1bbf3c465de4a24ab12fb7766a0003f6f9bce48b8b6a886158c4d569452dc5"}, + {file = "jiter-0.10.0-cp311-cp311-win32.whl", hash = "sha256:db16e4848b7e826edca4ccdd5b145939758dadf0dc06e7007ad0e9cfb5928ae7"}, + {file = "jiter-0.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:9c9c1d5f10e18909e993f9641f12fe1c77b3e9b533ee94ffa970acc14ded3812"}, + {file = "jiter-0.10.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1e274728e4a5345a6dde2d343c8da018b9d4bd4350f5a472fa91f66fda44911b"}, + {file = "jiter-0.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7202ae396446c988cb2a5feb33a543ab2165b786ac97f53b59aafb803fef0744"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23ba7722d6748b6920ed02a8f1726fb4b33e0fd2f3f621816a8b486c66410ab2"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:371eab43c0a288537d30e1f0b193bc4eca90439fc08a022dd83e5e07500ed026"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c675736059020365cebc845a820214765162728b51ab1e03a1b7b3abb70f74c"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0c5867d40ab716e4684858e4887489685968a47e3ba222e44cde6e4a2154f959"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395bb9a26111b60141757d874d27fdea01b17e8fac958b91c20128ba8f4acc8a"}, + {file = "jiter-0.10.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6842184aed5cdb07e0c7e20e5bdcfafe33515ee1741a6835353bb45fe5d1bd95"}, + {file = "jiter-0.10.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:62755d1bcea9876770d4df713d82606c8c1a3dca88ff39046b85a048566d56ea"}, + {file = "jiter-0.10.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:533efbce2cacec78d5ba73a41756beff8431dfa1694b6346ce7af3a12c42202b"}, + {file = "jiter-0.10.0-cp312-cp312-win32.whl", hash = "sha256:8be921f0cadd245e981b964dfbcd6fd4bc4e254cdc069490416dd7a2632ecc01"}, + {file = "jiter-0.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7c7d785ae9dda68c2678532a5a1581347e9c15362ae9f6e68f3fdbfb64f2e49"}, + {file = "jiter-0.10.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e0588107ec8e11b6f5ef0e0d656fb2803ac6cf94a96b2b9fc675c0e3ab5e8644"}, + {file = "jiter-0.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cafc4628b616dc32530c20ee53d71589816cf385dd9449633e910d596b1f5c8a"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:520ef6d981172693786a49ff5b09eda72a42e539f14788124a07530f785c3ad6"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:554dedfd05937f8fc45d17ebdf298fe7e0c77458232bcb73d9fbbf4c6455f5b3"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5bc299da7789deacf95f64052d97f75c16d4fc8c4c214a22bf8d859a4288a1c2"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5161e201172de298a8a1baad95eb85db4fb90e902353b1f6a41d64ea64644e25"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e2227db6ba93cb3e2bf67c87e594adde0609f146344e8207e8730364db27041"}, + {file = "jiter-0.10.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:15acb267ea5e2c64515574b06a8bf393fbfee6a50eb1673614aa45f4613c0cca"}, + {file = "jiter-0.10.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:901b92f2e2947dc6dfcb52fd624453862e16665ea909a08398dde19c0731b7f4"}, + {file = "jiter-0.10.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d0cb9a125d5a3ec971a094a845eadde2db0de85b33c9f13eb94a0c63d463879e"}, + {file = "jiter-0.10.0-cp313-cp313-win32.whl", hash = "sha256:48a403277ad1ee208fb930bdf91745e4d2d6e47253eedc96e2559d1e6527006d"}, + {file = "jiter-0.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:75f9eb72ecb640619c29bf714e78c9c46c9c4eaafd644bf78577ede459f330d4"}, + {file = "jiter-0.10.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:28ed2a4c05a1f32ef0e1d24c2611330219fed727dae01789f4a335617634b1ca"}, + {file = "jiter-0.10.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:14a4c418b1ec86a195f1ca69da8b23e8926c752b685af665ce30777233dfe070"}, + {file = "jiter-0.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d7bfed2fe1fe0e4dda6ef682cee888ba444b21e7a6553e03252e4feb6cf0adca"}, + {file = "jiter-0.10.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:5e9251a5e83fab8d87799d3e1a46cb4b7f2919b895c6f4483629ed2446f66522"}, + {file = "jiter-0.10.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:023aa0204126fe5b87ccbcd75c8a0d0261b9abdbbf46d55e7ae9f8e22424eeb8"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3c189c4f1779c05f75fc17c0c1267594ed918996a231593a21a5ca5438445216"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15720084d90d1098ca0229352607cd68256c76991f6b374af96f36920eae13c4"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4f2fb68e5f1cfee30e2b2a09549a00683e0fde4c6a2ab88c94072fc33cb7426"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce541693355fc6da424c08b7edf39a2895f58d6ea17d92cc2b168d20907dee12"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31c50c40272e189d50006ad5c73883caabb73d4e9748a688b216e85a9a9ca3b9"}, + {file = "jiter-0.10.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fa3402a2ff9815960e0372a47b75c76979d74402448509ccd49a275fa983ef8a"}, + {file = "jiter-0.10.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:1956f934dca32d7bb647ea21d06d93ca40868b505c228556d3373cbd255ce853"}, + {file = "jiter-0.10.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:fcedb049bdfc555e261d6f65a6abe1d5ad68825b7202ccb9692636c70fcced86"}, + {file = "jiter-0.10.0-cp314-cp314-win32.whl", hash = "sha256:ac509f7eccca54b2a29daeb516fb95b6f0bd0d0d8084efaf8ed5dfc7b9f0b357"}, + {file = "jiter-0.10.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5ed975b83a2b8639356151cef5c0d597c68376fc4922b45d0eb384ac058cfa00"}, + {file = "jiter-0.10.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3aa96f2abba33dc77f79b4cf791840230375f9534e5fac927ccceb58c5e604a5"}, + {file = "jiter-0.10.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:bd6292a43c0fc09ce7c154ec0fa646a536b877d1e8f2f96c19707f65355b5a4d"}, + {file = "jiter-0.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:39de429dcaeb6808d75ffe9effefe96a4903c6a4b376b2f6d08d77c1aaee2f18"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52ce124f13a7a616fad3bb723f2bfb537d78239d1f7f219566dc52b6f2a9e48d"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:166f3606f11920f9a1746b2eea84fa2c0a5d50fd313c38bdea4edc072000b0af"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:28dcecbb4ba402916034fc14eba7709f250c4d24b0c43fc94d187ee0580af181"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86c5aa6910f9bebcc7bc4f8bc461aff68504388b43bfe5e5c0bd21efa33b52f4"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ceeb52d242b315d7f1f74b441b6a167f78cea801ad7c11c36da77ff2d42e8a28"}, + {file = "jiter-0.10.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ff76d8887c8c8ee1e772274fcf8cc1071c2c58590d13e33bd12d02dc9a560397"}, + {file = "jiter-0.10.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:a9be4d0fa2b79f7222a88aa488bd89e2ae0a0a5b189462a12def6ece2faa45f1"}, + {file = "jiter-0.10.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9ab7fd8738094139b6c1ab1822d6f2000ebe41515c537235fd45dabe13ec9324"}, + {file = "jiter-0.10.0-cp39-cp39-win32.whl", hash = "sha256:5f51e048540dd27f204ff4a87f5d79294ea0aa3aa552aca34934588cf27023cf"}, + {file = "jiter-0.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:1b28302349dc65703a9e4ead16f163b1c339efffbe1049c30a44b001a2a4fff9"}, + {file = "jiter-0.10.0.tar.gz", hash = "sha256:07a7142c38aacc85194391108dc91b5b57093c978a9932bd86a36862759d9500"}, ] [[package]] @@ -1792,6 +2678,7 @@ version = "1.1.0" description = "jsonref is a library for automatic dereferencing of JSON Reference objects for Python." optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9"}, {file = "jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552"}, @@ -1799,13 +2686,14 @@ files = [ [[package]] name = "jsonschema" -version = "4.23.0" +version = "4.25.0" description = "An implementation of JSON Schema validation for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, - {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, + {file = "jsonschema-4.25.0-py3-none-any.whl", hash = "sha256:24c2e8da302de79c8b9382fee3e76b355e44d2a4364bb207159ce10b517bd716"}, + {file = "jsonschema-4.25.0.tar.gz", hash = "sha256:e63acf5c11762c0e6672ffb61482bdf57f0876684d8d249c0fe2d730d48bc55f"}, ] [package.dependencies] @@ -1816,31 +2704,63 @@ rpds-py = ">=0.7.1" [package.extras] format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] [[package]] name = "jsonschema-specifications" -version = "2024.10.1" +version = "2025.4.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf"}, - {file = "jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272"}, + {file = "jsonschema_specifications-2025.4.1-py3-none-any.whl", hash = "sha256:4653bffbd6584f7de83a67e0d620ef16900b390ddc7939d56684d6c81e33f1af"}, + {file = "jsonschema_specifications-2025.4.1.tar.gz", hash = "sha256:630159c9f4dbea161a6a2205c3011cc4f18ff381b189fff48bb39b9bf26ae608"}, ] [package.dependencies] referencing = ">=0.31.0" +[[package]] +name = "keyring" +version = "25.6.0" +description = "Store and access your passwords safely." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "keyring-25.6.0-py3-none-any.whl", hash = "sha256:552a3f7af126ece7ed5c89753650eec89c7eaae8617d0aa4d9ad2b75111266bd"}, + {file = "keyring-25.6.0.tar.gz", hash = "sha256:0b39998aa941431eb3d9b0d4b2460bc773b9df6fed7621c2dfb291a7e0187a66"}, +] + +[package.dependencies] +importlib_metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} +"jaraco.classes" = "*" +"jaraco.context" = "*" +"jaraco.functools" = "*" +jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} +pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} +SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} + +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] +completion = ["shtab (>=1.1.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["pyfakefs", "pytest (>=6,!=8.1.*)"] +type = ["pygobject-stubs", "pytest-mypy", "shtab", "types-pywin32"] + [[package]] name = "launchdarkly-eventsource" -version = "1.2.0" +version = "1.3.0" description = "LaunchDarkly SSE Client" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "launchdarkly_eventsource-1.2.0-py3-none-any.whl", hash = "sha256:9b5ec7149e2ad9995be22ad5361deb480c229701e6b0cc799e94aa14f067b77b"}, - {file = "launchdarkly_eventsource-1.2.0.tar.gz", hash = "sha256:8cb3301ec0daeb5e17eaa37b3b65f6660fab851b317e69271185ef2fb42c2fde"}, + {file = "launchdarkly_eventsource-1.3.0-py3-none-any.whl", hash = "sha256:7afecee7a0f306630e4364994e0816abbbdbaf143fe43b3428ed122fa4e0d487"}, + {file = "launchdarkly_eventsource-1.3.0.tar.gz", hash = "sha256:5029199b2c344aee2806bbe8784965e9c0197d052d0bab7e1396a94c159f6bc8"}, ] [package.dependencies] @@ -1848,19 +2768,20 @@ urllib3 = ">=1.26.0,<3" [[package]] name = "launchdarkly-server-sdk" -version = "9.8.0" +version = "9.12.0" description = "LaunchDarkly SDK for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "launchdarkly_server_sdk-9.8.0-py3-none-any.whl", hash = "sha256:8cb72f3cd283bd3b1954d59b8197f1467b35d5c10449904aaf560d59d4ceb368"}, - {file = "launchdarkly_server_sdk-9.8.0.tar.gz", hash = "sha256:e50a5eef770a5d0c609cf823c60ad9526f2f645e67efc638af31e7582ff62050"}, + {file = "launchdarkly_server_sdk-9.12.0-py3-none-any.whl", hash = "sha256:c7351e26e6b695b968d49888e967691aae60ded82d73a41a74d59d11252a1413"}, + {file = "launchdarkly_server_sdk-9.12.0.tar.gz", hash = "sha256:219b100d7569e4f528b6028cbd2a5d61f13539f2b44ed43fc173bb4f456ee580"}, ] [package.dependencies] certifi = ">=2018.4.16" expiringdict = ">=1.1.4" -launchdarkly-eventsource = ">=1.1.0,<2.0.0" +launchdarkly-eventsource = ">=1.2.4,<2.0.0" pyRFC3339 = ">=1.0" semver = ">=2.10.2" urllib3 = ">=1.26.0,<3" @@ -1871,12 +2792,69 @@ dynamodb = ["boto3 (>=1.9.71)"] redis = ["redis (>=2.10.5)"] test-filesource = ["pyyaml (>=5.3.1)", "watchdog (>=3.0.0)"] +[[package]] +name = "litellm" +version = "1.74.15.post2" +description = "Library to easily interface with LLM API providers" +optional = false +python-versions = "!=2.7.*,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,!=3.7.*,>=3.8" +groups = ["main"] +files = [ + {file = "litellm-1.74.15.post2.tar.gz", hash = "sha256:8eddb1c8a6a5a7048f8ba16e652aba23d6ca996dd87cb853c874ba375aa32479"}, +] + +[package.dependencies] +aiohttp = ">=3.10" +click = "*" +httpx = ">=0.23.0" +importlib-metadata = ">=6.8.0" +jinja2 = ">=3.1.2,<4.0.0" +jsonschema = ">=4.22.0,<5.0.0" +openai = ">=1.68.2" +pydantic = ">=2.5.0,<3.0.0" +python-dotenv = ">=0.2.0" +tiktoken = ">=0.7.0" +tokenizers = "*" + +[package.extras] +caching = ["diskcache (>=5.6.1,<6.0.0)"] +extra-proxy = ["azure-identity (>=1.15.0,<2.0.0)", "azure-keyvault-secrets (>=4.8.0,<5.0.0)", "google-cloud-kms (>=2.21.3,<3.0.0)", "prisma (==0.11.0)", "redisvl (>=0.4.1,<0.5.0) ; python_version >= \"3.9\" and python_version < \"3.14\"", "resend (>=0.8.0,<0.9.0)"] +mlflow = ["mlflow (>3.1.4) ; python_version >= \"3.10\""] +proxy = ["PyJWT (>=2.8.0,<3.0.0)", "apscheduler (>=3.10.4,<4.0.0)", "azure-identity (>=1.15.0,<2.0.0)", "azure-storage-blob (>=12.25.1,<13.0.0)", "backoff", "boto3 (==1.34.34)", "cryptography (>=43.0.1,<44.0.0)", "fastapi (>=0.115.5,<0.116.0)", "fastapi-sso (>=0.16.0,<0.17.0)", "gunicorn (>=23.0.0,<24.0.0)", "litellm-enterprise (==0.1.16)", "litellm-proxy-extras (==0.2.16)", "mcp (>=1.10.0,<2.0.0) ; python_version >= \"3.10\"", "orjson (>=3.9.7,<4.0.0)", "polars (>=1.31.0,<2.0.0) ; python_version >= \"3.10\"", "pynacl (>=1.5.0,<2.0.0)", "python-multipart (>=0.0.18,<0.0.19)", "pyyaml (>=6.0.1,<7.0.0)", "rich (==13.7.1)", "rq", "uvicorn (>=0.29.0,<0.30.0)", "uvloop (>=0.21.0,<0.22.0) ; sys_platform != \"win32\"", "websockets (>=13.1.0,<14.0.0)"] +semantic-router = ["semantic-router ; python_version >= \"3.9\""] +utils = ["numpydoc"] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +description = "Python port of markdown-it. Markdown parsing, done right!" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147"}, + {file = "markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3"}, +] + +[package.dependencies] +mdurl = ">=0.1,<1.0" + +[package.extras] +benchmarking = ["psutil", "pytest", "pytest-benchmark"] +compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "markdown-it-pyrs", "mistletoe (>=1.0,<2.0)", "mistune (>=3.0,<4.0)", "panflute (>=2.3,<3.0)"] +linkify = ["linkify-it-py (>=1,<3)"] +plugins = ["mdit-py-plugins (>=0.5.0)"] +profiling = ["gprof2dot"] +rtd = ["ipykernel", "jupyter_sphinx", "mdit-py-plugins (>=0.5.0)", "myst-parser", "pyyaml", "sphinx", "sphinx-book-theme (>=1.0,<2.0)", "sphinx-copybutton", "sphinx-design"] +testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions", "requests"] + [[package]] name = "markupsafe" version = "3.0.2" description = "Safely add untrusted strings to HTML/XML markup." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, @@ -1947,110 +2925,277 @@ version = "0.7.0" description = "McCabe checker, plugin for flake8" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, ] +[[package]] +name = "mdurl" +version = "0.1.2" +description = "Markdown URL utilities" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"}, + {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"}, +] + +[[package]] +name = "mem0ai" +version = "0.1.115" +description = "Long-term memory for AI Agents" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "mem0ai-0.1.115-py3-none-any.whl", hash = "sha256:29310bd5bcab644f7a4dbf87bd1afd878eb68458a2fb36cfcbf20bdff46fbdaf"}, + {file = "mem0ai-0.1.115.tar.gz", hash = "sha256:147a6593604188acd30281c40171112aed9f16e196fa528627430c15e00f1e32"}, +] + +[package.dependencies] +openai = ">=1.33.0" +posthog = ">=3.5.0" +pydantic = ">=2.7.3" +pytz = ">=2024.1" +qdrant-client = ">=1.9.1" +sqlalchemy = ">=2.0.31" + +[package.extras] +dev = ["isort (>=5.13.2)", "pytest (>=8.2.2)", "ruff (>=0.6.5)"] +extras = ["boto3 (>=1.34.0)", "elasticsearch (>=8.0.0)", "langchain-community (>=0.0.0)", "langchain-memgraph (>=0.1.0)", "opensearch-py (>=2.0.0)", "sentence-transformers (>=5.0.0)"] +graph = ["langchain-aws (>=0.2.23)", "langchain-neo4j (>=0.4.0)", "neo4j (>=5.23.1)", "rank-bm25 (>=0.2.2)"] +llms = ["google-genai (>=1.0.0)", "google-generativeai (>=0.3.0)", "groq (>=0.3.0)", "litellm (>=0.1.0)", "ollama (>=0.1.0)", "together (>=0.2.10)", "vertexai (>=0.1.0)"] +test = ["pytest (>=8.2.2)", "pytest-asyncio (>=0.23.7)", "pytest-mock (>=3.14.0)"] +vector-stores = ["azure-search-documents (>=11.4.0b8)", "chromadb (>=0.4.24)", "faiss-cpu (>=1.7.4)", "pinecone (<=7.3.0)", "pinecone-text (>=0.10.0)", "pymochow (>=2.2.9)", "pymongo (>=4.13.2)", "upstash-vector (>=0.1.0)", "vecs (>=0.4.0)", "weaviate-client (>=4.4.0)"] + +[[package]] +name = "more-itertools" +version = "10.7.0" +description = "More routines for operating on iterables, beyond itertools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "more_itertools-10.7.0-py3-none-any.whl", hash = "sha256:d43980384673cb07d2f7d2d918c616b30c659c089ee23953f601d6609c67510e"}, + {file = "more_itertools-10.7.0.tar.gz", hash = "sha256:9fddd5403be01a94b204faadcff459ec3568cf110265d3c54323e1e866ad29d3"}, +] + +[[package]] +name = "moviepy" +version = "2.2.1" +description = "Video editing with Python" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "moviepy-2.2.1-py3-none-any.whl", hash = "sha256:6b56803fec2ac54b557404126ac1160e65448e03798fa282bd23e8fab3795060"}, + {file = "moviepy-2.2.1.tar.gz", hash = "sha256:c80cb56815ece94e5e3e2d361aa40070eeb30a09d23a24c4e684d03e16deacb1"}, +] + +[package.dependencies] +decorator = ">=4.0.2,<6.0" +imageio = ">=2.5,<3.0" +imageio_ffmpeg = ">=0.2.0" +numpy = ">=1.25.0" +pillow = ">=9.2.0,<12.0" +proglog = "<=1.0.0" +python-dotenv = ">=0.10" + +[package.extras] +doc = ["Sphinx (==6.*)", "numpydoc (<2.0)", "pydata-sphinx-theme (==0.13)", "sphinx_design"] +lint = ["black (>=23.7.0)", "flake8 (>=6.0.0)", "flake8-absolute-import (>=1.0)", "flake8-docstrings (>=1.7.0)", "flake8-implicit-str-concat (==0.4.0)", "flake8-rst-docstrings (>=0.3)", "isort (>=5.12)", "pre-commit (>=3.3)"] +test = ["coveralls (>=3.0,<4.0)", "pytest (>=3.0.0,<7.0.0)", "pytest-cov (>=2.5.1,<3.0)"] + +[[package]] +name = "msgpack" +version = "1.1.1" +description = "MessagePack serializer" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "msgpack-1.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:353b6fc0c36fde68b661a12949d7d49f8f51ff5fa019c1e47c87c4ff34b080ed"}, + {file = "msgpack-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:79c408fcf76a958491b4e3b103d1c417044544b68e96d06432a189b43d1215c8"}, + {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:78426096939c2c7482bf31ef15ca219a9e24460289c00dd0b94411040bb73ad2"}, + {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b17ba27727a36cb73aabacaa44b13090feb88a01d012c0f4be70c00f75048b4"}, + {file = "msgpack-1.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7a17ac1ea6ec3c7687d70201cfda3b1e8061466f28f686c24f627cae4ea8efd0"}, + {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:88d1e966c9235c1d4e2afac21ca83933ba59537e2e2727a999bf3f515ca2af26"}, + {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:f6d58656842e1b2ddbe07f43f56b10a60f2ba5826164910968f5933e5178af75"}, + {file = "msgpack-1.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:96decdfc4adcbc087f5ea7ebdcfd3dee9a13358cae6e81d54be962efc38f6338"}, + {file = "msgpack-1.1.1-cp310-cp310-win32.whl", hash = "sha256:6640fd979ca9a212e4bcdf6eb74051ade2c690b862b679bfcb60ae46e6dc4bfd"}, + {file = "msgpack-1.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:8b65b53204fe1bd037c40c4148d00ef918eb2108d24c9aaa20bc31f9810ce0a8"}, + {file = "msgpack-1.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:71ef05c1726884e44f8b1d1773604ab5d4d17729d8491403a705e649116c9558"}, + {file = "msgpack-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:36043272c6aede309d29d56851f8841ba907a1a3d04435e43e8a19928e243c1d"}, + {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a32747b1b39c3ac27d0670122b57e6e57f28eefb725e0b625618d1b59bf9d1e0"}, + {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a8b10fdb84a43e50d38057b06901ec9da52baac6983d3f709d8507f3889d43f"}, + {file = "msgpack-1.1.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba0c325c3f485dc54ec298d8b024e134acf07c10d494ffa24373bea729acf704"}, + {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:88daaf7d146e48ec71212ce21109b66e06a98e5e44dca47d853cbfe171d6c8d2"}, + {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8b55ea20dc59b181d3f47103f113e6f28a5e1c89fd5b67b9140edb442ab67f2"}, + {file = "msgpack-1.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4a28e8072ae9779f20427af07f53bbb8b4aa81151054e882aee333b158da8752"}, + {file = "msgpack-1.1.1-cp311-cp311-win32.whl", hash = "sha256:7da8831f9a0fdb526621ba09a281fadc58ea12701bc709e7b8cbc362feabc295"}, + {file = "msgpack-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:5fd1b58e1431008a57247d6e7cc4faa41c3607e8e7d4aaf81f7c29ea013cb458"}, + {file = "msgpack-1.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ae497b11f4c21558d95de9f64fff7053544f4d1a17731c866143ed6bb4591238"}, + {file = "msgpack-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:33be9ab121df9b6b461ff91baac6f2731f83d9b27ed948c5b9d1978ae28bf157"}, + {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f64ae8fe7ffba251fecb8408540c34ee9df1c26674c50c4544d72dbf792e5ce"}, + {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a494554874691720ba5891c9b0b39474ba43ffb1aaf32a5dac874effb1619e1a"}, + {file = "msgpack-1.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cb643284ab0ed26f6957d969fe0dd8bb17beb567beb8998140b5e38a90974f6c"}, + {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d275a9e3c81b1093c060c3837e580c37f47c51eca031f7b5fb76f7b8470f5f9b"}, + {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4fd6b577e4541676e0cc9ddc1709d25014d3ad9a66caa19962c4f5de30fc09ef"}, + {file = "msgpack-1.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb29aaa613c0a1c40d1af111abf025f1732cab333f96f285d6a93b934738a68a"}, + {file = "msgpack-1.1.1-cp312-cp312-win32.whl", hash = "sha256:870b9a626280c86cff9c576ec0d9cbcc54a1e5ebda9cd26dab12baf41fee218c"}, + {file = "msgpack-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:5692095123007180dca3e788bb4c399cc26626da51629a31d40207cb262e67f4"}, + {file = "msgpack-1.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3765afa6bd4832fc11c3749be4ba4b69a0e8d7b728f78e68120a157a4c5d41f0"}, + {file = "msgpack-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8ddb2bcfd1a8b9e431c8d6f4f7db0773084e107730ecf3472f1dfe9ad583f3d9"}, + {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:196a736f0526a03653d829d7d4c5500a97eea3648aebfd4b6743875f28aa2af8"}, + {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d592d06e3cc2f537ceeeb23d38799c6ad83255289bb84c2e5792e5a8dea268a"}, + {file = "msgpack-1.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4df2311b0ce24f06ba253fda361f938dfecd7b961576f9be3f3fbd60e87130ac"}, + {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e4141c5a32b5e37905b5940aacbc59739f036930367d7acce7a64e4dec1f5e0b"}, + {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b1ce7f41670c5a69e1389420436f41385b1aa2504c3b0c30620764b15dded2e7"}, + {file = "msgpack-1.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4147151acabb9caed4e474c3344181e91ff7a388b888f1e19ea04f7e73dc7ad5"}, + {file = "msgpack-1.1.1-cp313-cp313-win32.whl", hash = "sha256:500e85823a27d6d9bba1d057c871b4210c1dd6fb01fbb764e37e4e8847376323"}, + {file = "msgpack-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:6d489fba546295983abd142812bda76b57e33d0b9f5d5b71c09a583285506f69"}, + {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bba1be28247e68994355e028dcd668316db30c1f758d3241a7b903ac78dcd285"}, + {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8f93dcddb243159c9e4109c9750ba5b335ab8d48d9522c5308cd05d7e3ce600"}, + {file = "msgpack-1.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2fbbc0b906a24038c9958a1ba7ae0918ad35b06cb449d398b76a7d08470b0ed9"}, + {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:61e35a55a546a1690d9d09effaa436c25ae6130573b6ee9829c37ef0f18d5e78"}, + {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:1abfc6e949b352dadf4bce0eb78023212ec5ac42f6abfd469ce91d783c149c2a"}, + {file = "msgpack-1.1.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:996f2609ddf0142daba4cefd767d6db26958aac8439ee41db9cc0db9f4c4c3a6"}, + {file = "msgpack-1.1.1-cp38-cp38-win32.whl", hash = "sha256:4d3237b224b930d58e9d83c81c0dba7aacc20fcc2f89c1e5423aa0529a4cd142"}, + {file = "msgpack-1.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:da8f41e602574ece93dbbda1fab24650d6bf2a24089f9e9dbb4f5730ec1e58ad"}, + {file = "msgpack-1.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f5be6b6bc52fad84d010cb45433720327ce886009d862f46b26d4d154001994b"}, + {file = "msgpack-1.1.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3a89cd8c087ea67e64844287ea52888239cbd2940884eafd2dcd25754fb72232"}, + {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d75f3807a9900a7d575d8d6674a3a47e9f227e8716256f35bc6f03fc597ffbf"}, + {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d182dac0221eb8faef2e6f44701812b467c02674a322c739355c39e94730cdbf"}, + {file = "msgpack-1.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1b13fe0fb4aac1aa5320cd693b297fe6fdef0e7bea5518cbc2dd5299f873ae90"}, + {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:435807eeb1bc791ceb3247d13c79868deb22184e1fc4224808750f0d7d1affc1"}, + {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:4835d17af722609a45e16037bb1d4d78b7bdf19d6c0128116d178956618c4e88"}, + {file = "msgpack-1.1.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a8ef6e342c137888ebbfb233e02b8fbd689bb5b5fcc59b34711ac47ebd504478"}, + {file = "msgpack-1.1.1-cp39-cp39-win32.whl", hash = "sha256:61abccf9de335d9efd149e2fff97ed5974f2481b3353772e8e2dd3402ba2bd57"}, + {file = "msgpack-1.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:40eae974c873b2992fd36424a5d9407f93e97656d999f43fca9d29f820899084"}, + {file = "msgpack-1.1.1.tar.gz", hash = "sha256:77b79ce34a2bdab2594f490c8e80dd62a02d650b91a75159a63ec413b8d104cd"}, +] + [[package]] name = "multidict" -version = "6.1.0" +version = "6.6.3" description = "multidict implementation" optional = false -python-versions = ">=3.8" -files = [ - {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3380252550e372e8511d49481bd836264c009adb826b23fefcc5dd3c69692f60"}, - {file = "multidict-6.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:99f826cbf970077383d7de805c0681799491cb939c25450b9b5b3ced03ca99f1"}, - {file = "multidict-6.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a114d03b938376557927ab23f1e950827c3b893ccb94b62fd95d430fd0e5cf53"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1c416351ee6271b2f49b56ad7f308072f6f44b37118d69c2cad94f3fa8a40d5"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b5d83030255983181005e6cfbac1617ce9746b219bc2aad52201ad121226581"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e97b5e938051226dc025ec80980c285b053ffb1e25a3db2a3aa3bc046bf7f56"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d618649d4e70ac6efcbba75be98b26ef5078faad23592f9b51ca492953012429"}, - {file = "multidict-6.1.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10524ebd769727ac77ef2278390fb0068d83f3acb7773792a5080f2b0abf7748"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ff3827aef427c89a25cc96ded1759271a93603aba9fb977a6d264648ebf989db"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:06809f4f0f7ab7ea2cabf9caca7d79c22c0758b58a71f9d32943ae13c7ace056"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f179dee3b863ab1c59580ff60f9d99f632f34ccb38bf67a33ec6b3ecadd0fd76"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:aaed8b0562be4a0876ee3b6946f6869b7bcdb571a5d1496683505944e268b160"}, - {file = "multidict-6.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3c8b88a2ccf5493b6c8da9076fb151ba106960a2df90c2633f342f120751a9e7"}, - {file = "multidict-6.1.0-cp310-cp310-win32.whl", hash = "sha256:4a9cb68166a34117d6646c0023c7b759bf197bee5ad4272f420a0141d7eb03a0"}, - {file = "multidict-6.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:20b9b5fbe0b88d0bdef2012ef7dee867f874b72528cf1d08f1d59b0e3850129d"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3efe2c2cb5763f2f1b275ad2bf7a287d3f7ebbef35648a9726e3b69284a4f3d6"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c7053d3b0353a8b9de430a4f4b4268ac9a4fb3481af37dfe49825bf45ca24156"}, - {file = "multidict-6.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:27e5fc84ccef8dfaabb09d82b7d179c7cf1a3fbc8a966f8274fcb4ab2eb4cadb"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2b90b43e696f25c62656389d32236e049568b39320e2735d51f08fd362761b"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d83a047959d38a7ff552ff94be767b7fd79b831ad1cd9920662db05fec24fe72"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d1a9dd711d0877a1ece3d2e4fea11a8e75741ca21954c919406b44e7cf971304"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec2abea24d98246b94913b76a125e855eb5c434f7c46546046372fe60f666351"}, - {file = "multidict-6.1.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4867cafcbc6585e4b678876c489b9273b13e9fff9f6d6d66add5e15d11d926cb"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5b48204e8d955c47c55b72779802b219a39acc3ee3d0116d5080c388970b76e3"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d8fff389528cad1618fb4b26b95550327495462cd745d879a8c7c2115248e399"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a7a9541cd308eed5e30318430a9c74d2132e9a8cb46b901326272d780bf2d423"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:da1758c76f50c39a2efd5e9859ce7d776317eb1dd34317c8152ac9251fc574a3"}, - {file = "multidict-6.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c943a53e9186688b45b323602298ab727d8865d8c9ee0b17f8d62d14b56f0753"}, - {file = "multidict-6.1.0-cp311-cp311-win32.whl", hash = "sha256:90f8717cb649eea3504091e640a1b8568faad18bd4b9fcd692853a04475a4b80"}, - {file = "multidict-6.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:82176036e65644a6cc5bd619f65f6f19781e8ec2e5330f51aa9ada7504cc1926"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b04772ed465fa3cc947db808fa306d79b43e896beb677a56fb2347ca1a49c1fa"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6180c0ae073bddeb5a97a38c03f30c233e0a4d39cd86166251617d1bbd0af436"}, - {file = "multidict-6.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:071120490b47aa997cca00666923a83f02c7fbb44f71cf7f136df753f7fa8761"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50b3a2710631848991d0bf7de077502e8994c804bb805aeb2925a981de58ec2e"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b58c621844d55e71c1b7f7c498ce5aa6985d743a1a59034c57a905b3f153c1ef"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:55b6d90641869892caa9ca42ff913f7ff1c5ece06474fbd32fb2cf6834726c95"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b820514bfc0b98a30e3d85462084779900347e4d49267f747ff54060cc33925"}, - {file = "multidict-6.1.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:10a9b09aba0c5b48c53761b7c720aaaf7cf236d5fe394cd399c7ba662d5f9966"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e16bf3e5fc9f44632affb159d30a437bfe286ce9e02754759be5536b169b305"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76f364861c3bfc98cbbcbd402d83454ed9e01a5224bb3a28bf70002a230f73e2"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:820c661588bd01a0aa62a1283f20d2be4281b086f80dad9e955e690c75fb54a2"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0e5f362e895bc5b9e67fe6e4ded2492d8124bdf817827f33c5b46c2fe3ffaca6"}, - {file = "multidict-6.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ec660d19bbc671e3a6443325f07263be452c453ac9e512f5eb935e7d4ac28b3"}, - {file = "multidict-6.1.0-cp312-cp312-win32.whl", hash = "sha256:58130ecf8f7b8112cdb841486404f1282b9c86ccb30d3519faf301b2e5659133"}, - {file = "multidict-6.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:188215fc0aafb8e03341995e7c4797860181562380f81ed0a87ff455b70bf1f1"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d569388c381b24671589335a3be6e1d45546c2988c2ebe30fdcada8457a31008"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:052e10d2d37810b99cc170b785945421141bf7bb7d2f8799d431e7db229c385f"}, - {file = "multidict-6.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f90c822a402cb865e396a504f9fc8173ef34212a342d92e362ca498cad308e28"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b225d95519a5bf73860323e633a664b0d85ad3d5bede6d30d95b35d4dfe8805b"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:23bfd518810af7de1116313ebd9092cb9aa629beb12f6ed631ad53356ed6b86c"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5c09fcfdccdd0b57867577b719c69e347a436b86cd83747f179dbf0cc0d4c1f3"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf6bea52ec97e95560af5ae576bdac3aa3aae0b6758c6efa115236d9e07dae44"}, - {file = "multidict-6.1.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57feec87371dbb3520da6192213c7d6fc892d5589a93db548331954de8248fd2"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0c3f390dc53279cbc8ba976e5f8035eab997829066756d811616b652b00a23a3"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:59bfeae4b25ec05b34f1956eaa1cb38032282cd4dfabc5056d0a1ec4d696d3aa"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f59caeaf7632cc633b5cf6fc449372b83bbdf0da4ae04d5be36118e46cc0aa"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:37bb93b2178e02b7b618893990941900fd25b6b9ac0fa49931a40aecdf083fe4"}, - {file = "multidict-6.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4e9f48f58c2c523d5a06faea47866cd35b32655c46b443f163d08c6d0ddb17d6"}, - {file = "multidict-6.1.0-cp313-cp313-win32.whl", hash = "sha256:3a37ffb35399029b45c6cc33640a92bef403c9fd388acce75cdc88f58bd19a81"}, - {file = "multidict-6.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:e9aa71e15d9d9beaad2c6b9319edcdc0a49a43ef5c0a4c8265ca9ee7d6c67774"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:db7457bac39421addd0c8449933ac32d8042aae84a14911a757ae6ca3eef1392"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d094ddec350a2fb899fec68d8353c78233debde9b7d8b4beeafa70825f1c281a"}, - {file = "multidict-6.1.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5845c1fd4866bb5dd3125d89b90e57ed3138241540897de748cdf19de8a2fca2"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9079dfc6a70abe341f521f78405b8949f96db48da98aeb43f9907f342f627cdc"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3914f5aaa0f36d5d60e8ece6a308ee1c9784cd75ec8151062614657a114c4478"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c08be4f460903e5a9d0f76818db3250f12e9c344e79314d1d570fc69d7f4eae4"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d093be959277cb7dee84b801eb1af388b6ad3ca6a6b6bf1ed7585895789d027d"}, - {file = "multidict-6.1.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3702ea6872c5a2a4eeefa6ffd36b042e9773f05b1f37ae3ef7264b1163c2dcf6"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:2090f6a85cafc5b2db085124d752757c9d251548cedabe9bd31afe6363e0aff2"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:f67f217af4b1ff66c68a87318012de788dd95fcfeb24cc889011f4e1c7454dfd"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:189f652a87e876098bbc67b4da1049afb5f5dfbaa310dd67c594b01c10388db6"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:6bb5992037f7a9eff7991ebe4273ea7f51f1c1c511e6a2ce511d0e7bdb754492"}, - {file = "multidict-6.1.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f4c2b9e770c4e393876e35a7046879d195cd123b4f116d299d442b335bcd"}, - {file = "multidict-6.1.0-cp38-cp38-win32.whl", hash = "sha256:e27bbb6d14416713a8bd7aaa1313c0fc8d44ee48d74497a0ff4c3a1b6ccb5167"}, - {file = "multidict-6.1.0-cp38-cp38-win_amd64.whl", hash = "sha256:22f3105d4fb15c8f57ff3959a58fcab6ce36814486500cd7485651230ad4d4ef"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:4e18b656c5e844539d506a0a06432274d7bd52a7487e6828c63a63d69185626c"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a185f876e69897a6f3325c3f19f26a297fa058c5e456bfcff8015e9a27e83ae1"}, - {file = "multidict-6.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:ab7c4ceb38d91570a650dba194e1ca87c2b543488fe9309b4212694174fd539c"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e617fb6b0b6953fffd762669610c1c4ffd05632c138d61ac7e14ad187870669c"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16e5f4bf4e603eb1fdd5d8180f1a25f30056f22e55ce51fb3d6ad4ab29f7d96f"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c035da3f544b1882bac24115f3e2e8760f10a0107614fc9839fd232200b875"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:957cf8e4b6e123a9eea554fa7ebc85674674b713551de587eb318a2df3e00255"}, - {file = "multidict-6.1.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:483a6aea59cb89904e1ceabd2b47368b5600fb7de78a6e4a2c2987b2d256cf30"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:87701f25a2352e5bf7454caa64757642734da9f6b11384c1f9d1a8e699758057"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:682b987361e5fd7a139ed565e30d81fd81e9629acc7d925a205366877d8c8657"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:ce2186a7df133a9c895dea3331ddc5ddad42cdd0d1ea2f0a51e5d161e4762f28"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9f636b730f7e8cb19feb87094949ba54ee5357440b9658b2a32a5ce4bce53972"}, - {file = "multidict-6.1.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:73eae06aa53af2ea5270cc066dcaf02cc60d2994bbb2c4ef5764949257d10f43"}, - {file = "multidict-6.1.0-cp39-cp39-win32.whl", hash = "sha256:1ca0083e80e791cffc6efce7660ad24af66c8d4079d2a750b29001b53ff59ada"}, - {file = "multidict-6.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:aa466da5b15ccea564bdab9c89175c762bc12825f4659c11227f515cee76fa4a"}, - {file = "multidict-6.1.0-py3-none-any.whl", hash = "sha256:48e171e52d1c4d33888e529b999e5900356b9ae588c2f09a52dcefb158b27506"}, - {file = "multidict-6.1.0.tar.gz", hash = "sha256:22ae2ebf9b0c69d206c003e2f6a914ea33f0a932d4aa16f236afc049d9958f4a"}, +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a2be5b7b35271f7fff1397204ba6708365e3d773579fe2a30625e16c4b4ce817"}, + {file = "multidict-6.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:12f4581d2930840295c461764b9a65732ec01250b46c6b2c510d7ee68872b140"}, + {file = "multidict-6.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dd7793bab517e706c9ed9d7310b06c8672fd0aeee5781bfad612f56b8e0f7d14"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:72d8815f2cd3cf3df0f83cac3f3ef801d908b2d90409ae28102e0553af85545a"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:531e331a2ee53543ab32b16334e2deb26f4e6b9b28e41f8e0c87e99a6c8e2d69"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42ca5aa9329a63be8dc49040f63817d1ac980e02eeddba763a9ae5b4027b9c9c"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:208b9b9757060b9faa6f11ab4bc52846e4f3c2fb8b14d5680c8aac80af3dc751"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:acf6b97bd0884891af6a8b43d0f586ab2fcf8e717cbd47ab4bdddc09e20652d8"}, + {file = "multidict-6.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68e9e12ed00e2089725669bdc88602b0b6f8d23c0c95e52b95f0bc69f7fe9b55"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:05db2f66c9addb10cfa226e1acb363450fab2ff8a6df73c622fefe2f5af6d4e7"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:0db58da8eafb514db832a1b44f8fa7906fdd102f7d982025f816a93ba45e3dcb"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14117a41c8fdb3ee19c743b1c027da0736fdb79584d61a766da53d399b71176c"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:877443eaaabcd0b74ff32ebeed6f6176c71850feb7d6a1d2db65945256ea535c"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:70b72e749a4f6e7ed8fb334fa8d8496384840319512746a5f42fa0aec79f4d61"}, + {file = "multidict-6.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:43571f785b86afd02b3855c5ac8e86ec921b760298d6f82ff2a61daf5a35330b"}, + {file = "multidict-6.6.3-cp310-cp310-win32.whl", hash = "sha256:20c5a0c3c13a15fd5ea86c42311859f970070e4e24de5a550e99d7c271d76318"}, + {file = "multidict-6.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:ab0a34a007704c625e25a9116c6770b4d3617a071c8a7c30cd338dfbadfe6485"}, + {file = "multidict-6.6.3-cp310-cp310-win_arm64.whl", hash = "sha256:769841d70ca8bdd140a715746199fc6473414bd02efd678d75681d2d6a8986c5"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:18f4eba0cbac3546b8ae31e0bbc55b02c801ae3cbaf80c247fcdd89b456ff58c"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef43b5dd842382329e4797c46f10748d8c2b6e0614f46b4afe4aee9ac33159df"}, + {file = "multidict-6.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bd1fd5eec01494e0f2e8e446a74a85d5e49afb63d75a9934e4a5423dba21d"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5bd8d6f793a787153956cd35e24f60485bf0651c238e207b9a54f7458b16d539"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bf99b4daf908c73856bd87ee0a2499c3c9a3d19bb04b9c6025e66af3fd07462"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b9e59946b49dafaf990fd9c17ceafa62976e8471a14952163d10a7a630413a9"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e2db616467070d0533832d204c54eea6836a5e628f2cb1e6dfd8cd6ba7277cb7"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7394888236621f61dcdd25189b2768ae5cc280f041029a5bcf1122ac63df79f9"}, + {file = "multidict-6.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f114d8478733ca7388e7c7e0ab34b72547476b97009d643644ac33d4d3fe1821"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdf22e4db76d323bcdc733514bf732e9fb349707c98d341d40ebcc6e9318ef3d"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e995a34c3d44ab511bfc11aa26869b9d66c2d8c799fa0e74b28a473a692532d6"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766a4a5996f54361d8d5a9050140aa5362fe48ce51c755a50c0bc3706460c430"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3893a0d7d28a7fe6ca7a1f760593bc13038d1d35daf52199d431b61d2660602b"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:934796c81ea996e61914ba58064920d6cad5d99140ac3167901eb932150e2e56"}, + {file = "multidict-6.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9ed948328aec2072bc00f05d961ceadfd3e9bfc2966c1319aeaf7b7c21219183"}, + {file = "multidict-6.6.3-cp311-cp311-win32.whl", hash = "sha256:9f5b28c074c76afc3e4c610c488e3493976fe0e596dd3db6c8ddfbb0134dcac5"}, + {file = "multidict-6.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:bc7f6fbc61b1c16050a389c630da0b32fc6d4a3d191394ab78972bf5edc568c2"}, + {file = "multidict-6.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:d4e47d8faffaae822fb5cba20937c048d4f734f43572e7079298a6c39fb172cb"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:056bebbeda16b2e38642d75e9e5310c484b7c24e3841dc0fb943206a72ec89d6"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e5f481cccb3c5c5e5de5d00b5141dc589c1047e60d07e85bbd7dea3d4580d63f"}, + {file = "multidict-6.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10bea2ee839a759ee368b5a6e47787f399b41e70cf0c20d90dfaf4158dfb4e55"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:2334cfb0fa9549d6ce2c21af2bfbcd3ac4ec3646b1b1581c88e3e2b1779ec92b"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8fee016722550a2276ca2cb5bb624480e0ed2bd49125b2b73b7010b9090e888"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5511cb35f5c50a2db21047c875eb42f308c5583edf96bd8ebf7d770a9d68f6d"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:712b348f7f449948e0a6c4564a21c7db965af900973a67db432d724619b3c680"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e4e15d2138ee2694e038e33b7c3da70e6b0ad8868b9f8094a72e1414aeda9c1a"}, + {file = "multidict-6.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8df25594989aebff8a130f7899fa03cbfcc5d2b5f4a461cf2518236fe6f15961"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:159ca68bfd284a8860f8d8112cf0521113bffd9c17568579e4d13d1f1dc76b65"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e098c17856a8c9ade81b4810888c5ad1914099657226283cab3062c0540b0643"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:67c92ed673049dec52d7ed39f8cf9ebbadf5032c774058b4406d18c8f8fe7063"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:bd0578596e3a835ef451784053cfd327d607fc39ea1a14812139339a18a0dbc3"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:346055630a2df2115cd23ae271910b4cae40f4e336773550dca4889b12916e75"}, + {file = "multidict-6.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:555ff55a359302b79de97e0468e9ee80637b0de1fce77721639f7cd9440b3a10"}, + {file = "multidict-6.6.3-cp312-cp312-win32.whl", hash = "sha256:73ab034fb8d58ff85c2bcbadc470efc3fafeea8affcf8722855fb94557f14cc5"}, + {file = "multidict-6.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:04cbcce84f63b9af41bad04a54d4cc4e60e90c35b9e6ccb130be2d75b71f8c17"}, + {file = "multidict-6.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:0f1130b896ecb52d2a1e615260f3ea2af55fa7dc3d7c3003ba0c3121a759b18b"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:540d3c06d48507357a7d57721e5094b4f7093399a0106c211f33540fdc374d55"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9c19cea2a690f04247d43f366d03e4eb110a0dc4cd1bbeee4d445435428ed35b"}, + {file = "multidict-6.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7af039820cfd00effec86bda5d8debef711a3e86a1d3772e85bea0f243a4bd65"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:500b84f51654fdc3944e936f2922114349bf8fdcac77c3092b03449f0e5bc2b3"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3fc723ab8a5c5ed6c50418e9bfcd8e6dceba6c271cee6728a10a4ed8561520c"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:94c47ea3ade005b5976789baaed66d4de4480d0a0bf31cef6edaa41c1e7b56a6"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dbc7cf464cc6d67e83e136c9f55726da3a30176f020a36ead246eceed87f1cd8"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:900eb9f9da25ada070f8ee4a23f884e0ee66fe4e1a38c3af644256a508ad81ca"}, + {file = "multidict-6.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c6df517cf177da5d47ab15407143a89cd1a23f8b335f3a28d57e8b0a3dbb884"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ef421045f13879e21c994b36e728d8e7d126c91a64b9185810ab51d474f27e7"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6c1e61bb4f80895c081790b6b09fa49e13566df8fbff817da3f85b3a8192e36b"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5e8523bb12d7623cd8300dbd91b9e439a46a028cd078ca695eb66ba31adee3c"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:ef58340cc896219e4e653dade08fea5c55c6df41bcc68122e3be3e9d873d9a7b"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc9dc435ec8699e7b602b94fe0cd4703e69273a01cbc34409af29e7820f777f1"}, + {file = "multidict-6.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9e864486ef4ab07db5e9cb997bad2b681514158d6954dd1958dfb163b83d53e6"}, + {file = "multidict-6.6.3-cp313-cp313-win32.whl", hash = "sha256:5633a82fba8e841bc5c5c06b16e21529573cd654f67fd833650a215520a6210e"}, + {file = "multidict-6.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:e93089c1570a4ad54c3714a12c2cef549dc9d58e97bcded193d928649cab78e9"}, + {file = "multidict-6.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:c60b401f192e79caec61f166da9c924e9f8bc65548d4246842df91651e83d600"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:02fd8f32d403a6ff13864b0851f1f523d4c988051eea0471d4f1fd8010f11134"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:f3aa090106b1543f3f87b2041eef3c156c8da2aed90c63a2fbed62d875c49c37"}, + {file = "multidict-6.6.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e924fb978615a5e33ff644cc42e6aa241effcf4f3322c09d4f8cebde95aff5f8"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:b9fe5a0e57c6dbd0e2ce81ca66272282c32cd11d31658ee9553849d91289e1c1"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b24576f208793ebae00280c59927c3b7c2a3b1655e443a25f753c4611bc1c373"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:135631cb6c58eac37d7ac0df380294fecdc026b28837fa07c02e459c7fb9c54e"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:274d416b0df887aef98f19f21578653982cfb8a05b4e187d4a17103322eeaf8f"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e252017a817fad7ce05cafbe5711ed40faeb580e63b16755a3a24e66fa1d87c0"}, + {file = "multidict-6.6.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2e4cc8d848cd4fe1cdee28c13ea79ab0ed37fc2e89dd77bac86a2e7959a8c3bc"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9e236a7094b9c4c1b7585f6b9cca34b9d833cf079f7e4c49e6a4a6ec9bfdc68f"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:e0cb0ab69915c55627c933f0b555a943d98ba71b4d1c57bc0d0a66e2567c7471"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:81ef2f64593aba09c5212a3d0f8c906a0d38d710a011f2f42759704d4557d3f2"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:b9cbc60010de3562545fa198bfc6d3825df430ea96d2cc509c39bd71e2e7d648"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:70d974eaaa37211390cd02ef93b7e938de564bbffa866f0b08d07e5e65da783d"}, + {file = "multidict-6.6.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3713303e4a6663c6d01d648a68f2848701001f3390a030edaaf3fc949c90bf7c"}, + {file = "multidict-6.6.3-cp313-cp313t-win32.whl", hash = "sha256:639ecc9fe7cd73f2495f62c213e964843826f44505a3e5d82805aa85cac6f89e"}, + {file = "multidict-6.6.3-cp313-cp313t-win_amd64.whl", hash = "sha256:9f97e181f344a0ef3881b573d31de8542cc0dbc559ec68c8f8b5ce2c2e91646d"}, + {file = "multidict-6.6.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ce8b7693da41a3c4fde5871c738a81490cea5496c671d74374c8ab889e1834fb"}, + {file = "multidict-6.6.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c8161b5a7778d3137ea2ee7ae8a08cce0010de3b00ac671c5ebddeaa17cefd22"}, + {file = "multidict-6.6.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1328201ee930f069961ae707d59c6627ac92e351ed5b92397cf534d1336ce557"}, + {file = "multidict-6.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b1db4d2093d6b235de76932febf9d50766cf49a5692277b2c28a501c9637f616"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53becb01dd8ebd19d1724bebe369cfa87e4e7f29abbbe5c14c98ce4c383e16cd"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41bb9d1d4c303886e2d85bade86e59885112a7f4277af5ad47ab919a2251f306"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:775b464d31dac90f23192af9c291dc9f423101857e33e9ebf0020a10bfcf4144"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d04d01f0a913202205a598246cf77826fe3baa5a63e9f6ccf1ab0601cf56eca0"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d25594d3b38a2e6cabfdcafef339f754ca6e81fbbdb6650ad773ea9775af35ab"}, + {file = "multidict-6.6.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:35712f1748d409e0707b165bf49f9f17f9e28ae85470c41615778f8d4f7d9609"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1c8082e5814b662de8589d6a06c17e77940d5539080cbab9fe6794b5241b76d9"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:61af8a4b771f1d4d000b3168c12c3120ccf7284502a94aa58c68a81f5afac090"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:448e4a9afccbf297577f2eaa586f07067441e7b63c8362a3540ba5a38dc0f14a"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:233ad16999afc2bbd3e534ad8dbe685ef8ee49a37dbc2cdc9514e57b6d589ced"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:bb933c891cd4da6bdcc9733d048e994e22e1883287ff7540c2a0f3b117605092"}, + {file = "multidict-6.6.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37b09ca60998e87734699e88c2363abfd457ed18cfbf88e4009a4e83788e63ed"}, + {file = "multidict-6.6.3-cp39-cp39-win32.whl", hash = "sha256:f54cb79d26d0cd420637d184af38f0668558f3c4bbe22ab7ad830e67249f2e0b"}, + {file = "multidict-6.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:295adc9c0551e5d5214b45cf29ca23dbc28c2d197a9c30d51aed9e037cb7c578"}, + {file = "multidict-6.6.3-cp39-cp39-win_arm64.whl", hash = "sha256:15332783596f227db50fb261c2c251a58ac3873c457f3a550a95d5c0aa3c770d"}, + {file = "multidict-6.6.3-py3-none-any.whl", hash = "sha256:8db10f29c7541fc5da4defd8cd697e1ca429db743fa716325f236079b96f775a"}, + {file = "multidict-6.6.3.tar.gz", hash = "sha256:798a9eb12dab0a6c2e29c1de6f3468af5cb2da6053a20dfa3344907eed0937cc"}, ] [package.dependencies] @@ -2058,13 +3203,26 @@ typing-extensions = {version = ">=4.1.0", markers = "python_version < \"3.11\""} [[package]] name = "mypy-extensions" -version = "1.0.0" +version = "1.1.0" description = "Type system extensions for programs checked with the mypy type checker." optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"}, + {file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"}, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +description = "Patch asyncio to allow nested event loops" +optional = false python-versions = ">=3.5" +groups = ["main"] files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, + {file = "nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c"}, + {file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"}, ] [[package]] @@ -2073,20 +3231,150 @@ version = "1.9.1" description = "Node.js virtual environment builder" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main", "dev"] files = [ {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "numpy" +version = "2.2.6" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "python_version < \"3.11\"" +files = [ + {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, + {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, + {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, + {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, + {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, + {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, + {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, + {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, + {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, + {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, + {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, + {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, +] + +[[package]] +name = "numpy" +version = "2.3.1" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.11" +groups = ["main"] +markers = "python_version >= \"3.11\"" +files = [ + {file = "numpy-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6ea9e48336a402551f52cd8f593343699003d2353daa4b72ce8d34f66b722070"}, + {file = "numpy-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5ccb7336eaf0e77c1635b232c141846493a588ec9ea777a7c24d7166bb8533ae"}, + {file = "numpy-2.3.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:0bb3a4a61e1d327e035275d2a993c96fa786e4913aa089843e6a2d9dd205c66a"}, + {file = "numpy-2.3.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:e344eb79dab01f1e838ebb67aab09965fb271d6da6b00adda26328ac27d4a66e"}, + {file = "numpy-2.3.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:467db865b392168ceb1ef1ffa6f5a86e62468c43e0cfb4ab6da667ede10e58db"}, + {file = "numpy-2.3.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:afed2ce4a84f6b0fc6c1ce734ff368cbf5a5e24e8954a338f3bdffa0718adffb"}, + {file = "numpy-2.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0025048b3c1557a20bc80d06fdeb8cc7fc193721484cca82b2cfa072fec71a93"}, + {file = "numpy-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5ee121b60aa509679b682819c602579e1df14a5b07fe95671c8849aad8f2115"}, + {file = "numpy-2.3.1-cp311-cp311-win32.whl", hash = "sha256:a8b740f5579ae4585831b3cf0e3b0425c667274f82a484866d2adf9570539369"}, + {file = "numpy-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:d4580adadc53311b163444f877e0789f1c8861e2698f6b2a4ca852fda154f3ff"}, + {file = "numpy-2.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:ec0bdafa906f95adc9a0c6f26a4871fa753f25caaa0e032578a30457bff0af6a"}, + {file = "numpy-2.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2959d8f268f3d8ee402b04a9ec4bb7604555aeacf78b360dc4ec27f1d508177d"}, + {file = "numpy-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:762e0c0c6b56bdedfef9a8e1d4538556438288c4276901ea008ae44091954e29"}, + {file = "numpy-2.3.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:867ef172a0976aaa1f1d1b63cf2090de8b636a7674607d514505fb7276ab08fc"}, + {file = "numpy-2.3.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:4e602e1b8682c2b833af89ba641ad4176053aaa50f5cacda1a27004352dde943"}, + {file = "numpy-2.3.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:8e333040d069eba1652fb08962ec5b76af7f2c7bce1df7e1418c8055cf776f25"}, + {file = "numpy-2.3.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:e7cbf5a5eafd8d230a3ce356d892512185230e4781a361229bd902ff403bc660"}, + {file = "numpy-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5f1b8f26d1086835f442286c1d9b64bb3974b0b1e41bb105358fd07d20872952"}, + {file = "numpy-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ee8340cb48c9b7a5899d1149eece41ca535513a9698098edbade2a8e7a84da77"}, + {file = "numpy-2.3.1-cp312-cp312-win32.whl", hash = "sha256:e772dda20a6002ef7061713dc1e2585bc1b534e7909b2030b5a46dae8ff077ab"}, + {file = "numpy-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:cfecc7822543abdea6de08758091da655ea2210b8ffa1faf116b940693d3df76"}, + {file = "numpy-2.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:7be91b2239af2658653c5bb6f1b8bccafaf08226a258caf78ce44710a0160d30"}, + {file = "numpy-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25a1992b0a3fdcdaec9f552ef10d8103186f5397ab45e2d25f8ac51b1a6b97e8"}, + {file = "numpy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7dea630156d39b02a63c18f508f85010230409db5b2927ba59c8ba4ab3e8272e"}, + {file = "numpy-2.3.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bada6058dd886061f10ea15f230ccf7dfff40572e99fef440a4a857c8728c9c0"}, + {file = "numpy-2.3.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:a894f3816eb17b29e4783e5873f92faf55b710c2519e5c351767c51f79d8526d"}, + {file = "numpy-2.3.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:18703df6c4a4fee55fd3d6e5a253d01c5d33a295409b03fda0c86b3ca2ff41a1"}, + {file = "numpy-2.3.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5902660491bd7a48b2ec16c23ccb9124b8abfd9583c5fdfa123fe6b421e03de1"}, + {file = "numpy-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36890eb9e9d2081137bd78d29050ba63b8dab95dff7912eadf1185e80074b2a0"}, + {file = "numpy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a780033466159c2270531e2b8ac063704592a0bc62ec4a1b991c7c40705eb0e8"}, + {file = "numpy-2.3.1-cp313-cp313-win32.whl", hash = "sha256:39bff12c076812595c3a306f22bfe49919c5513aa1e0e70fac756a0be7c2a2b8"}, + {file = "numpy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d5ee6eec45f08ce507a6570e06f2f879b374a552087a4179ea7838edbcbfa42"}, + {file = "numpy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c4d9e0a8368db90f93bd192bfa771ace63137c3488d198ee21dfb8e7771916e"}, + {file = "numpy-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:b0b5397374f32ec0649dd98c652a1798192042e715df918c20672c62fb52d4b8"}, + {file = "numpy-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c5bdf2015ccfcee8253fb8be695516ac4457c743473a43290fd36eba6a1777eb"}, + {file = "numpy-2.3.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d70f20df7f08b90a2062c1f07737dd340adccf2068d0f1b9b3d56e2038979fee"}, + {file = "numpy-2.3.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:2fb86b7e58f9ac50e1e9dd1290154107e47d1eef23a0ae9145ded06ea606f992"}, + {file = "numpy-2.3.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:23ab05b2d241f76cb883ce8b9a93a680752fbfcbd51c50eff0b88b979e471d8c"}, + {file = "numpy-2.3.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ce2ce9e5de4703a673e705183f64fd5da5bf36e7beddcb63a25ee2286e71ca48"}, + {file = "numpy-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c4913079974eeb5c16ccfd2b1f09354b8fed7e0d6f2cab933104a09a6419b1ee"}, + {file = "numpy-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:010ce9b4f00d5c036053ca684c77441f2f2c934fd23bee058b4d6f196efd8280"}, + {file = "numpy-2.3.1-cp313-cp313t-win32.whl", hash = "sha256:6269b9edfe32912584ec496d91b00b6d34282ca1d07eb10e82dfc780907d6c2e"}, + {file = "numpy-2.3.1-cp313-cp313t-win_amd64.whl", hash = "sha256:2a809637460e88a113e186e87f228d74ae2852a2e0c44de275263376f17b5bdc"}, + {file = "numpy-2.3.1-cp313-cp313t-win_arm64.whl", hash = "sha256:eccb9a159db9aed60800187bc47a6d3451553f0e1b08b068d8b277ddfbb9b244"}, + {file = "numpy-2.3.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ad506d4b09e684394c42c966ec1527f6ebc25da7f4da4b1b056606ffe446b8a3"}, + {file = "numpy-2.3.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ebb8603d45bc86bbd5edb0d63e52c5fd9e7945d3a503b77e486bd88dde67a19b"}, + {file = "numpy-2.3.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:15aa4c392ac396e2ad3d0a2680c0f0dee420f9fed14eef09bdb9450ee6dcb7b7"}, + {file = "numpy-2.3.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c6e0bf9d1a2f50d2b65a7cf56db37c095af17b59f6c132396f7c6d5dd76484df"}, + {file = "numpy-2.3.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eabd7e8740d494ce2b4ea0ff05afa1b7b291e978c0ae075487c51e8bd93c0c68"}, + {file = "numpy-2.3.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:e610832418a2bc09d974cc9fecebfa51e9532d6190223bc5ef6a7402ebf3b5cb"}, + {file = "numpy-2.3.1.tar.gz", hash = "sha256:1ec9ae20a4226da374362cca3c62cd753faf2f951440b0e3b98e93c235441d2b"}, +] + [[package]] name = "oauthlib" -version = "3.2.2" +version = "3.3.1" description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, - {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, + {file = "oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1"}, + {file = "oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9"}, ] [package.extras] @@ -2096,28 +3384,30 @@ signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] [[package]] name = "ollama" -version = "0.4.4" +version = "0.5.1" description = "The official Python client for Ollama." optional = false -python-versions = "<4.0,>=3.8" +python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "ollama-0.4.4-py3-none-any.whl", hash = "sha256:0f466e845e2205a1cbf5a2fef4640027b90beaa3b06c574426d8b6b17fd6e139"}, - {file = "ollama-0.4.4.tar.gz", hash = "sha256:e1db064273c739babc2dde9ea84029c4a43415354741b6c50939ddd3dd0f7ffb"}, + {file = "ollama-0.5.1-py3-none-any.whl", hash = "sha256:4c8839f35bc173c7057b1eb2cbe7f498c1a7e134eafc9192824c8aecb3617506"}, + {file = "ollama-0.5.1.tar.gz", hash = "sha256:5a799e4dc4e7af638b11e3ae588ab17623ee019e496caaf4323efbaa8feeff93"}, ] [package.dependencies] -httpx = ">=0.27.0,<0.28.0" -pydantic = ">=2.9.0,<3.0.0" +httpx = ">=0.27" +pydantic = ">=2.9" [[package]] name = "openai" -version = "1.57.4" +version = "1.97.1" description = "The official Python library for the openai API" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "openai-1.57.4-py3-none-any.whl", hash = "sha256:7def1ab2d52f196357ce31b9cfcf4181529ce00838286426bb35be81c035dafb"}, - {file = "openai-1.57.4.tar.gz", hash = "sha256:a8f071a3e9198e2818f63aade68e759417b9f62c0971bdb83de82504b70b77f7"}, + {file = "openai-1.97.1-py3-none-any.whl", hash = "sha256:4e96bbdf672ec3d44968c9ea39d2c375891db1acc1794668d8149d5fa6000606"}, + {file = "openai-1.97.1.tar.gz", hash = "sha256:a744b27ae624e3d4135225da9b1c89c107a2a7e5bc4c93e5b7b5214772ce7a4e"}, ] [package.dependencies] @@ -2131,22 +3421,26 @@ tqdm = ">4" typing-extensions = ">=4.11,<5" [package.extras] +aiohttp = ["aiohttp", "httpx-aiohttp (>=0.1.8)"] datalib = ["numpy (>=1)", "pandas (>=1.2.3)", "pandas-stubs (>=1.1.0.11)"] +realtime = ["websockets (>=13,<16)"] +voice-helpers = ["numpy (>=2.0.2)", "sounddevice (>=0.5.1)"] [[package]] name = "opentelemetry-api" -version = "1.29.0" +version = "1.35.0" description = "OpenTelemetry Python API" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "opentelemetry_api-1.29.0-py3-none-any.whl", hash = "sha256:5fcd94c4141cc49c736271f3e1efb777bebe9cc535759c54c936cca4f1b312b8"}, - {file = "opentelemetry_api-1.29.0.tar.gz", hash = "sha256:d04a6cf78aad09614f52964ecb38021e248f5714dc32c2e0d8fd99517b4d69cf"}, + {file = "opentelemetry_api-1.35.0-py3-none-any.whl", hash = "sha256:c4ea7e258a244858daf18474625e9cc0149b8ee354f37843415771a40c25ee06"}, + {file = "opentelemetry_api-1.35.0.tar.gz", hash = "sha256:a111b959bcfa5b4d7dffc2fbd6a241aa72dd78dd8e79b5b1662bda896c5d2ffe"}, ] [package.dependencies] -deprecated = ">=1.2.6" -importlib-metadata = ">=6.0,<=8.5.0" +importlib-metadata = ">=6.0,<8.8.0" +typing-extensions = ">=4.5.0" [[package]] name = "packaging" @@ -2154,6 +3448,7 @@ version = "24.2" description = "Core utilities for Python packages" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, @@ -2165,6 +3460,7 @@ version = "3.3.0" description = "RabbitMQ Focused AMQP low-level library" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0"}, {file = "pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b"}, @@ -2174,12 +3470,100 @@ files = [ codegen = ["lxml", "requests", "yapf"] testing = ["coverage", "flake8", "flake8-comprehensions", "flake8-deprecated", "flake8-import-order", "flake8-print", "flake8-quotes", "flake8-rst-docstrings", "flake8-tuple", "yapf"] +[[package]] +name = "pandas" +version = "2.3.1" +description = "Powerful data structures for data analysis, time series, and statistics" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pandas-2.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:22c2e866f7209ebc3a8f08d75766566aae02bcc91d196935a1d9e59c7b990ac9"}, + {file = "pandas-2.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3583d348546201aff730c8c47e49bc159833f971c2899d6097bce68b9112a4f1"}, + {file = "pandas-2.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f951fbb702dacd390561e0ea45cdd8ecfa7fb56935eb3dd78e306c19104b9b0"}, + {file = "pandas-2.3.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd05b72ec02ebfb993569b4931b2e16fbb4d6ad6ce80224a3ee838387d83a191"}, + {file = "pandas-2.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1b916a627919a247d865aed068eb65eb91a344b13f5b57ab9f610b7716c92de1"}, + {file = "pandas-2.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fe67dc676818c186d5a3d5425250e40f179c2a89145df477dd82945eaea89e97"}, + {file = "pandas-2.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:2eb789ae0274672acbd3c575b0598d213345660120a257b47b5dafdc618aec83"}, + {file = "pandas-2.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2b0540963d83431f5ce8870ea02a7430adca100cec8a050f0811f8e31035541b"}, + {file = "pandas-2.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fe7317f578c6a153912bd2292f02e40c1d8f253e93c599e82620c7f69755c74f"}, + {file = "pandas-2.3.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e6723a27ad7b244c0c79d8e7007092d7c8f0f11305770e2f4cd778b3ad5f9f85"}, + {file = "pandas-2.3.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3462c3735fe19f2638f2c3a40bd94ec2dc5ba13abbb032dd2fa1f540a075509d"}, + {file = "pandas-2.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:98bcc8b5bf7afed22cc753a28bc4d9e26e078e777066bc53fac7904ddef9a678"}, + {file = "pandas-2.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d544806b485ddf29e52d75b1f559142514e60ef58a832f74fb38e48d757b299"}, + {file = "pandas-2.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:b3cd4273d3cb3707b6fffd217204c52ed92859533e31dc03b7c5008aa933aaab"}, + {file = "pandas-2.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:689968e841136f9e542020698ee1c4fbe9caa2ed2213ae2388dc7b81721510d3"}, + {file = "pandas-2.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:025e92411c16cbe5bb2a4abc99732a6b132f439b8aab23a59fa593eb00704232"}, + {file = "pandas-2.3.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b7ff55f31c4fcb3e316e8f7fa194566b286d6ac430afec0d461163312c5841e"}, + {file = "pandas-2.3.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7dcb79bf373a47d2a40cf7232928eb7540155abbc460925c2c96d2d30b006eb4"}, + {file = "pandas-2.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56a342b231e8862c96bdb6ab97170e203ce511f4d0429589c8ede1ee8ece48b8"}, + {file = "pandas-2.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ca7ed14832bce68baef331f4d7f294411bed8efd032f8109d690df45e00c4679"}, + {file = "pandas-2.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ac942bfd0aca577bef61f2bc8da8147c4ef6879965ef883d8e8d5d2dc3e744b8"}, + {file = "pandas-2.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9026bd4a80108fac2239294a15ef9003c4ee191a0f64b90f170b40cfb7cf2d22"}, + {file = "pandas-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6de8547d4fdb12421e2d047a2c446c623ff4c11f47fddb6b9169eb98ffba485a"}, + {file = "pandas-2.3.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:782647ddc63c83133b2506912cc6b108140a38a37292102aaa19c81c83db2928"}, + {file = "pandas-2.3.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba6aff74075311fc88504b1db890187a3cd0f887a5b10f5525f8e2ef55bfdb9"}, + {file = "pandas-2.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e5635178b387bd2ba4ac040f82bc2ef6e6b500483975c4ebacd34bec945fda12"}, + {file = "pandas-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f3bf5ec947526106399a9e1d26d40ee2b259c66422efdf4de63c848492d91bb"}, + {file = "pandas-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:1c78cf43c8fde236342a1cb2c34bcff89564a7bfed7e474ed2fffa6aed03a956"}, + {file = "pandas-2.3.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8dfc17328e8da77be3cf9f47509e5637ba8f137148ed0e9b5241e1baf526e20a"}, + {file = "pandas-2.3.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ec6c851509364c59a5344458ab935e6451b31b818be467eb24b0fe89bd05b6b9"}, + {file = "pandas-2.3.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:911580460fc4884d9b05254b38a6bfadddfcc6aaef856fb5859e7ca202e45275"}, + {file = "pandas-2.3.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f4d6feeba91744872a600e6edbbd5b033005b431d5ae8379abee5bcfa479fab"}, + {file = "pandas-2.3.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fe37e757f462d31a9cd7580236a82f353f5713a80e059a29753cf938c6775d96"}, + {file = "pandas-2.3.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5db9637dbc24b631ff3707269ae4559bce4b7fd75c1c4d7e13f40edc42df4444"}, + {file = "pandas-2.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:4645f770f98d656f11c69e81aeb21c6fca076a44bed3dcbb9396a4311bc7f6d8"}, + {file = "pandas-2.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:342e59589cc454aaff7484d75b816a433350b3d7964d7847327edda4d532a2e3"}, + {file = "pandas-2.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d12f618d80379fde6af007f65f0c25bd3e40251dbd1636480dfffce2cf1e6da"}, + {file = "pandas-2.3.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd71c47a911da120d72ef173aeac0bf5241423f9bfea57320110a978457e069e"}, + {file = "pandas-2.3.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:09e3b1587f0f3b0913e21e8b32c3119174551deb4a4eba4a89bc7377947977e7"}, + {file = "pandas-2.3.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2323294c73ed50f612f67e2bf3ae45aea04dce5690778e08a09391897f35ff88"}, + {file = "pandas-2.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:b4b0de34dc8499c2db34000ef8baad684cfa4cbd836ecee05f323ebfba348c7d"}, + {file = "pandas-2.3.1.tar.gz", hash = "sha256:0a95b9ac964fe83ce317827f80304d37388ea77616b1425f0ae41c9d2d0d7bb2"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.22.4", markers = "python_version < \"3.11\""}, + {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, +] +python-dateutil = ">=2.8.2" +pytz = ">=2020.1" +tzdata = ">=2022.7" + +[package.extras] +all = ["PyQt5 (>=5.15.9)", "SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)", "beautifulsoup4 (>=4.11.2)", "bottleneck (>=1.3.6)", "dataframe-api-compat (>=0.1.7)", "fastparquet (>=2022.12.0)", "fsspec (>=2022.11.0)", "gcsfs (>=2022.11.0)", "html5lib (>=1.1)", "hypothesis (>=6.46.1)", "jinja2 (>=3.1.2)", "lxml (>=4.9.2)", "matplotlib (>=3.6.3)", "numba (>=0.56.4)", "numexpr (>=2.8.4)", "odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "pandas-gbq (>=0.19.0)", "psycopg2 (>=2.9.6)", "pyarrow (>=10.0.1)", "pymysql (>=1.0.2)", "pyreadstat (>=1.2.0)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "qtpy (>=2.3.0)", "s3fs (>=2022.11.0)", "scipy (>=1.10.0)", "tables (>=3.8.0)", "tabulate (>=0.9.0)", "xarray (>=2022.12.0)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)", "zstandard (>=0.19.0)"] +aws = ["s3fs (>=2022.11.0)"] +clipboard = ["PyQt5 (>=5.15.9)", "qtpy (>=2.3.0)"] +compression = ["zstandard (>=0.19.0)"] +computation = ["scipy (>=1.10.0)", "xarray (>=2022.12.0)"] +consortium-standard = ["dataframe-api-compat (>=0.1.7)"] +excel = ["odfpy (>=1.4.1)", "openpyxl (>=3.1.0)", "python-calamine (>=0.1.7)", "pyxlsb (>=1.0.10)", "xlrd (>=2.0.1)", "xlsxwriter (>=3.0.5)"] +feather = ["pyarrow (>=10.0.1)"] +fss = ["fsspec (>=2022.11.0)"] +gcp = ["gcsfs (>=2022.11.0)", "pandas-gbq (>=0.19.0)"] +hdf5 = ["tables (>=3.8.0)"] +html = ["beautifulsoup4 (>=4.11.2)", "html5lib (>=1.1)", "lxml (>=4.9.2)"] +mysql = ["SQLAlchemy (>=2.0.0)", "pymysql (>=1.0.2)"] +output-formatting = ["jinja2 (>=3.1.2)", "tabulate (>=0.9.0)"] +parquet = ["pyarrow (>=10.0.1)"] +performance = ["bottleneck (>=1.3.6)", "numba (>=0.56.4)", "numexpr (>=2.8.4)"] +plot = ["matplotlib (>=3.6.3)"] +postgresql = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "psycopg2 (>=2.9.6)"] +pyarrow = ["pyarrow (>=10.0.1)"] +spss = ["pyreadstat (>=1.2.0)"] +sql-other = ["SQLAlchemy (>=2.0.0)", "adbc-driver-postgresql (>=0.8.0)", "adbc-driver-sqlite (>=0.8.0)"] +test = ["hypothesis (>=6.46.1)", "pytest (>=7.3.2)", "pytest-xdist (>=2.2.0)"] +xml = ["lxml (>=4.9.2)"] + [[package]] name = "pastel" version = "0.2.1" description = "Bring colors to your terminal." optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["dev"] files = [ {file = "pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364"}, {file = "pastel-0.2.1.tar.gz", hash = "sha256:e6581ac04e973cac858828c6202c1e1e81fee1dc7de7683f3e1ffe0bfd8a573d"}, @@ -2191,28 +3575,192 @@ version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, ] +[[package]] +name = "pbs-installer" +version = "2025.7.12" +description = "Installer for Python Build Standalone" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pbs_installer-2025.7.12-py3-none-any.whl", hash = "sha256:d73414224fceb60d4a07bea97facd9acc05de792dd7becc90a7f22383e7c1cab"}, + {file = "pbs_installer-2025.7.12.tar.gz", hash = "sha256:343b8905e1da3cd4b03b68d630086330dde1814294963b77d2664b18b5002ac6"}, +] + +[package.dependencies] +httpx = {version = ">=0.27.0,<1", optional = true, markers = "extra == \"download\""} +zstandard = {version = ">=0.21.0", optional = true, markers = "extra == \"install\""} + +[package.extras] +all = ["pbs-installer[download,install]"] +download = ["httpx (>=0.27.0,<1)"] +install = ["zstandard (>=0.21.0)"] + +[[package]] +name = "pika" +version = "1.3.2" +description = "Pika Python AMQP Client Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pika-1.3.2-py3-none-any.whl", hash = "sha256:0779a7c1fafd805672796085560d290213a465e4f6f76a6fb19e378d8041a14f"}, + {file = "pika-1.3.2.tar.gz", hash = "sha256:b2a327ddddf8570b4965b3576ac77091b850262d34ce8c1d8cb4e4146aa4145f"}, +] + +[package.extras] +gevent = ["gevent"] +tornado = ["tornado"] +twisted = ["twisted"] + +[[package]] +name = "pillow" +version = "11.3.0" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860"}, + {file = "pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50"}, + {file = "pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9"}, + {file = "pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e"}, + {file = "pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6"}, + {file = "pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f"}, + {file = "pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722"}, + {file = "pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58"}, + {file = "pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e"}, + {file = "pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94"}, + {file = "pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0"}, + {file = "pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac"}, + {file = "pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4"}, + {file = "pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7"}, + {file = "pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809"}, + {file = "pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d"}, + {file = "pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149"}, + {file = "pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d"}, + {file = "pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8"}, + {file = "pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c"}, + {file = "pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805"}, + {file = "pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2"}, + {file = "pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b"}, + {file = "pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3"}, + {file = "pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51"}, + {file = "pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e"}, + {file = "pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8"}, + {file = "pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe"}, + {file = "pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c"}, + {file = "pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788"}, + {file = "pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31"}, + {file = "pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12"}, + {file = "pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027"}, + {file = "pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874"}, + {file = "pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a"}, + {file = "pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214"}, + {file = "pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635"}, + {file = "pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae"}, + {file = "pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b"}, + {file = "pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50"}, + {file = "pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b"}, + {file = "pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12"}, + {file = "pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db"}, + {file = "pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:48d254f8a4c776de343051023eb61ffe818299eeac478da55227d96e241de53f"}, + {file = "pillow-11.3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7aee118e30a4cf54fdd873bd3a29de51e29105ab11f9aad8c32123f58c8f8081"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:23cff760a9049c502721bdb743a7cb3e03365fafcdfc2ef9784610714166e5a4"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6359a3bc43f57d5b375d1ad54a0074318a0844d11b76abccf478c37c986d3cfc"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:092c80c76635f5ecb10f3f83d76716165c96f5229addbd1ec2bdbbda7d496e06"}, + {file = "pillow-11.3.0-cp39-cp39-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cadc9e0ea0a2431124cde7e1697106471fc4c1da01530e679b2391c37d3fbb3a"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:6a418691000f2a418c9135a7cf0d797c1bb7d9a485e61fe8e7722845b95ef978"}, + {file = "pillow-11.3.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:97afb3a00b65cc0804d1c7abddbf090a81eaac02768af58cbdcaaa0a931e0b6d"}, + {file = "pillow-11.3.0-cp39-cp39-win32.whl", hash = "sha256:ea944117a7974ae78059fcc1800e5d3295172bb97035c0c1d9345fca1419da71"}, + {file = "pillow-11.3.0-cp39-cp39-win_amd64.whl", hash = "sha256:e5c5858ad8ec655450a7c7df532e9842cf8df7cc349df7225c60d5d348c8aada"}, + {file = "pillow-11.3.0-cp39-cp39-win_arm64.whl", hash = "sha256:6abdbfd3aea42be05702a8dd98832329c167ee84400a1d1f61ab11437f1717eb"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a"}, + {file = "pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7"}, + {file = "pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8"}, + {file = "pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + [[package]] name = "pinecone" -version = "5.4.2" +version = "7.3.0" description = "Pinecone client and SDK" optional = false -python-versions = "<4.0,>=3.8" +python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "pinecone-5.4.2-py3-none-any.whl", hash = "sha256:1fad082c66a50a229b58cda0c3a1fa0083532dc9de8303015fe4071cb25c19a8"}, - {file = "pinecone-5.4.2.tar.gz", hash = "sha256:23e8aaa73b400bb11a3b626c4129284fb170f19025b82f65bd89cbb0dab2b873"}, + {file = "pinecone-7.3.0-py3-none-any.whl", hash = "sha256:315b8fef20320bef723ecbb695dec0aafa75d8434d86e01e5a0e85933e1009a8"}, + {file = "pinecone-7.3.0.tar.gz", hash = "sha256:307edc155621d487c20dc71b76c3ad5d6f799569ba42064190d03917954f9a7b"}, ] [package.dependencies] certifi = ">=2019.11.17" -pinecone-plugin-inference = ">=2.0.0,<4.0.0" +pinecone-plugin-assistant = ">=1.6.0,<2.0.0" pinecone-plugin-interface = ">=0.0.7,<0.0.8" python-dateutil = ">=2.5.3" -tqdm = ">=4.64.1" typing-extensions = ">=3.7.4" urllib3 = [ {version = ">=1.26.0", markers = "python_version >= \"3.8\" and python_version < \"3.12\""}, @@ -2220,21 +3768,24 @@ urllib3 = [ ] [package.extras] -grpc = ["googleapis-common-protos (>=1.53.0)", "grpcio (>=1.44.0)", "grpcio (>=1.59.0)", "lz4 (>=3.1.3)", "protobuf (>=4.25,<5.0)", "protoc-gen-openapiv2 (>=0.0.1,<0.0.2)"] +asyncio = ["aiohttp (>=3.9.0)", "aiohttp-retry (>=2.9.1,<3.0.0)"] +grpc = ["googleapis-common-protos (>=1.66.0)", "grpcio (>=1.44.0) ; python_version >= \"3.8\" and python_version < \"3.11\"", "grpcio (>=1.59.0) ; python_version >= \"3.11\" and python_version < \"4.0\"", "grpcio (>=1.68.0) ; python_version >= \"3.13\" and python_version < \"4.0\"", "lz4 (>=3.1.3)", "protobuf (>=5.29,<6.0)", "protoc-gen-openapiv2 (>=0.0.1,<0.0.2)"] [[package]] -name = "pinecone-plugin-inference" -version = "3.1.0" -description = "Embeddings plugin for Pinecone SDK" +name = "pinecone-plugin-assistant" +version = "1.7.0" +description = "Assistant plugin for Pinecone SDK" optional = false -python-versions = "<4.0,>=3.8" +python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "pinecone_plugin_inference-3.1.0-py3-none-any.whl", hash = "sha256:96e861527bd41e90d58b7e76abd4e713d9af28f63e76a51864dfb9cf7180e3df"}, - {file = "pinecone_plugin_inference-3.1.0.tar.gz", hash = "sha256:eff826178e1fe448577be2ff3d8dbb072befbbdc2d888e214624523a1c37cd8d"}, + {file = "pinecone_plugin_assistant-1.7.0-py3-none-any.whl", hash = "sha256:864cb8e7930588e6c2da97c6d44f0240969195f43fa303c5db76cbc12bf903a5"}, + {file = "pinecone_plugin_assistant-1.7.0.tar.gz", hash = "sha256:e26e3ba10a8b71c3da0d777cff407668022e82963c4913d0ffeb6c552721e482"}, ] [package.dependencies] -pinecone-plugin-interface = ">=0.0.7,<0.0.8" +packaging = ">=24.2,<25.0" +requests = ">=2.32.3,<3.0.0" [[package]] name = "pinecone-plugin-interface" @@ -2242,84 +3793,235 @@ version = "0.0.7" description = "Plugin interface for the Pinecone python client" optional = false python-versions = "<4.0,>=3.8" +groups = ["main"] files = [ {file = "pinecone_plugin_interface-0.0.7-py3-none-any.whl", hash = "sha256:875857ad9c9fc8bbc074dbe780d187a2afd21f5bfe0f3b08601924a61ef1bba8"}, {file = "pinecone_plugin_interface-0.0.7.tar.gz", hash = "sha256:b8e6675e41847333aa13923cc44daa3f85676d7157324682dc1640588a982846"}, ] +[[package]] +name = "pkginfo" +version = "1.12.1.2" +description = "Query metadata from sdists / bdists / installed packages." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pkginfo-1.12.1.2-py3-none-any.whl", hash = "sha256:c783ac885519cab2c34927ccfa6bf64b5a704d7c69afaea583dd9b7afe969343"}, + {file = "pkginfo-1.12.1.2.tar.gz", hash = "sha256:5cd957824ac36f140260964eba3c6be6442a8359b8c48f4adf90210f33a04b7b"}, +] + +[package.extras] +testing = ["pytest", "pytest-cov", "wheel"] + [[package]] name = "platformdirs" -version = "4.3.6" +version = "4.3.8" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, ] [package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "playwright" +version = "1.54.0" +description = "A high-level API to automate web browsers" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "playwright-1.54.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:bf3b845af744370f1bd2286c2a9536f474cc8a88dc995b72ea9a5be714c9a77d"}, + {file = "playwright-1.54.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:780928b3ca2077aea90414b37e54edd0c4bbb57d1aafc42f7aa0b3fd2c2fac02"}, + {file = "playwright-1.54.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:81d0b6f28843b27f288cfe438af0a12a4851de57998009a519ea84cee6fbbfb9"}, + {file = "playwright-1.54.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:09919f45cc74c64afb5432646d7fef0d19fff50990c862cb8d9b0577093f40cc"}, + {file = "playwright-1.54.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ae206c55737e8e3eae51fb385d61c0312eeef31535643bb6232741b41b6fdc"}, + {file = "playwright-1.54.0-py3-none-win32.whl", hash = "sha256:0b108622ffb6906e28566f3f31721cd57dda637d7e41c430287804ac01911f56"}, + {file = "playwright-1.54.0-py3-none-win_amd64.whl", hash = "sha256:9e5aee9ae5ab1fdd44cd64153313a2045b136fcbcfb2541cc0a3d909132671a2"}, + {file = "playwright-1.54.0-py3-none-win_arm64.whl", hash = "sha256:a975815971f7b8dca505c441a4c56de1aeb56a211290f8cc214eeef5524e8d75"}, +] + +[package.dependencies] +greenlet = ">=3.1.1,<4.0.0" +pyee = ">=13,<14" [[package]] name = "pluggy" -version = "1.5.0" +version = "1.6.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, ] [package.extras] dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] +testing = ["coverage", "pytest", "pytest-benchmark"] [[package]] name = "poethepoet" -version = "0.31.1" -description = "A task runner that works well with poetry." +version = "0.36.0" +description = "A task runner that works well with poetry and uv." optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ - {file = "poethepoet-0.31.1-py3-none-any.whl", hash = "sha256:7fdfa0ac6074be9936723e7231b5bfaad2923e96c674a9857e81d326cf8ccdc2"}, - {file = "poethepoet-0.31.1.tar.gz", hash = "sha256:d6b66074edf85daf115bb916eae0afd6387d19e1562e1c9ef7d61d5c585696aa"}, + {file = "poethepoet-0.36.0-py3-none-any.whl", hash = "sha256:693e3c1eae9f6731d3613c3c0c40f747d3c5c68a375beda42e590a63c5623308"}, + {file = "poethepoet-0.36.0.tar.gz", hash = "sha256:2217b49cb4e4c64af0b42ff8c4814b17f02e107d38bc461542517348ede25663"}, ] [package.dependencies] pastel = ">=0.2.1,<0.3.0" -pyyaml = ">=6.0.2,<7.0.0" +pyyaml = ">=6.0.2,<7.0" tomli = {version = ">=1.2.2", markers = "python_version < \"3.11\""} [package.extras] -poetry-plugin = ["poetry (>=1.0,<2.0)"] +poetry-plugin = ["poetry (>=1.2.0,<3.0.0) ; python_version < \"4.0\""] + +[[package]] +name = "poetry" +version = "2.1.1" +description = "Python dependency management and packaging made easy." +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "poetry-2.1.1-py3-none-any.whl", hash = "sha256:1d433880bd5b401327ddee789ccfe9ff197bf3b0cd240f0bc7cc99c84d14b16c"}, + {file = "poetry-2.1.1.tar.gz", hash = "sha256:d82673865bf13d6cd0dacf28c69a89670456d8df2f9e5da82bfb5f833ba00efc"}, +] + +[package.dependencies] +build = ">=1.2.1,<2.0.0" +cachecontrol = {version = ">=0.14.0,<0.15.0", extras = ["filecache"]} +cleo = ">=2.1.0,<3.0.0" +dulwich = ">=0.22.6,<0.23.0" +fastjsonschema = ">=2.18.0,<3.0.0" +findpython = ">=0.6.2,<0.7.0" +installer = ">=0.7.0,<0.8.0" +keyring = ">=25.1.0,<26.0.0" +packaging = ">=24.0" +pbs-installer = {version = ">=2025.1.6,<2026.0.0", extras = ["download", "install"]} +pkginfo = ">=1.12,<2.0" +platformdirs = ">=3.0.0,<5" +poetry-core = "2.1.1" +pyproject-hooks = ">=1.0.0,<2.0.0" +requests = ">=2.26,<3.0" +requests-toolbelt = ">=1.0.0,<2.0.0" +shellingham = ">=1.5,<2.0" +tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""} +tomlkit = ">=0.11.4,<1.0.0" +trove-classifiers = ">=2022.5.19" +virtualenv = ">=20.26.6,<21.0.0" +xattr = {version = ">=1.0.0,<2.0.0", markers = "sys_platform == \"darwin\""} + +[[package]] +name = "poetry-core" +version = "2.1.1" +description = "Poetry PEP 517 Build Backend" +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "poetry_core-2.1.1-py3-none-any.whl", hash = "sha256:bc3b0382ab4d00d5d780277fd0aad1580eb4403613b37fc60fec407b5bee1fe6"}, + {file = "poetry_core-2.1.1.tar.gz", hash = "sha256:c1a1f6f00e4254742f40988a8caf665549101cf9991122cd5de1198897768b1a"}, +] + +[[package]] +name = "portalocker" +version = "2.10.1" +description = "Wraps the portalocker recipe for easy usage" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "portalocker-2.10.1-py3-none-any.whl", hash = "sha256:53a5984ebc86a025552264b459b46a2086e269b21823cb572f8f28ee759e45bf"}, + {file = "portalocker-2.10.1.tar.gz", hash = "sha256:ef1bf844e878ab08aee7e40184156e1151f228f103aa5c6bd0724cc330960f8f"}, +] + +[package.dependencies] +pywin32 = {version = ">=226", markers = "platform_system == \"Windows\""} + +[package.extras] +docs = ["sphinx (>=1.7.1)"] +redis = ["redis"] +tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "pytest-timeout (>=2.1.0)", "redis", "sphinx (>=6.0.0)", "types-redis"] [[package]] name = "postgrest" -version = "0.18.0" +version = "1.1.1" description = "PostgREST client for Python. This library provides an ORM interface to PostgREST." optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "postgrest-0.18.0-py3-none-any.whl", hash = "sha256:200baad0d23fee986b3a0ffd3e07bfe0cdd40e09760f11e8e13a6c0c2376d5fa"}, - {file = "postgrest-0.18.0.tar.gz", hash = "sha256:29c1a94801a17eb9ad590189993fe5a7a6d8c1bfc11a3c9d0ce7ba146454ebb3"}, + {file = "postgrest-1.1.1-py3-none-any.whl", hash = "sha256:98a6035ee1d14288484bfe36235942c5fb2d26af6d8120dfe3efbe007859251a"}, + {file = "postgrest-1.1.1.tar.gz", hash = "sha256:f3bb3e8c4602775c75c844a31f565f5f3dd584df4d36d683f0b67d01a86be322"}, ] [package.dependencies] deprecation = ">=2.1.0,<3.0.0" -httpx = {version = ">=0.26,<0.28", extras = ["http2"]} +httpx = {version = ">=0.26,<0.29", extras = ["http2"]} pydantic = ">=1.9,<3.0" strenum = {version = ">=0.4.9,<0.5.0", markers = "python_version < \"3.11\""} +[[package]] +name = "posthog" +version = "6.1.1" +description = "Integrate PostHog into any python application." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "posthog-6.1.1-py3-none-any.whl", hash = "sha256:329fd3d06b4d54cec925f47235bd8e327c91403c2f9ec38f1deb849535934dba"}, + {file = "posthog-6.1.1.tar.gz", hash = "sha256:b453f54c4a2589da859fd575dd3bf86fcb40580727ec399535f268b1b9f318b8"}, +] + +[package.dependencies] +backoff = ">=1.10.0" +distro = ">=1.5.0" +python-dateutil = ">=2.2" +requests = ">=2.7,<3.0" +six = ">=1.5" +typing-extensions = ">=4.2.0" + +[package.extras] +dev = ["django-stubs", "lxml", "mypy", "mypy-baseline", "packaging", "pre-commit", "pydantic", "ruff", "setuptools", "tomli", "tomli_w", "twine", "types-mock", "types-python-dateutil", "types-requests", "types-setuptools", "types-six", "wheel"] +langchain = ["langchain (>=0.2.0)"] +test = ["anthropic", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=0.3.15)", "langchain-community (>=0.3.25)", "langchain-core (>=0.3.65)", "langchain-openai (>=0.3.22)", "langgraph (>=0.4.8)", "mock (>=2.0.0)", "openai", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"] + +[[package]] +name = "postmarker" +version = "1.0" +description = "Python client library for Postmark API" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "postmarker-1.0-py3-none-any.whl", hash = "sha256:0fa49f236c7193650896cbf31bbfac34043e352574c6c7e3e2ad2b954704f064"}, + {file = "postmarker-1.0.tar.gz", hash = "sha256:e735303fdf8ede667a1c6e64a95a96e97f0dabbeca726d0ae1f066bdd799fe34"}, +] + +[package.dependencies] +requests = ">=2.20.0" + [[package]] name = "praw" version = "7.8.1" description = "Python Reddit API Wrapper." optional = false python-versions = "~=3.8" +groups = ["main"] files = [ {file = "praw-7.8.1-py3-none-any.whl", hash = "sha256:15917a81a06e20ff0aaaf1358481f4588449fa2421233040cb25e5c8202a3e2f"}, {file = "praw-7.8.1.tar.gz", hash = "sha256:3c5767909f71e48853eb6335fef7b50a43cbe3da728cdfb16d3be92904b0a4d8"}, @@ -2343,6 +4045,7 @@ version = "2.4.0" description = "\"Low-level communication layer for PRAW 4+." optional = false python-versions = "~=3.8" +groups = ["main"] files = [ {file = "prawcore-2.4.0-py3-none-any.whl", hash = "sha256:29af5da58d85704b439ad3c820873ad541f4535e00bb98c66f0fbcc8c603065a"}, {file = "prawcore-2.4.0.tar.gz", hash = "sha256:b7b2b5a1d04406e086ab4e79988dc794df16059862f329f4c6a43ed09986c335"}, @@ -2357,12 +4060,32 @@ dev = ["packaging", "prawcore[lint]", "prawcore[test]"] lint = ["pre-commit", "ruff (>=0.0.291)"] test = ["betamax (>=0.8,<0.9)", "pytest (>=2.7.3)", "urllib3 (==1.26.*)"] +[[package]] +name = "pre-commit" +version = "4.2.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd"}, + {file = "pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "prisma" version = "0.15.0" description = "Prisma Client Python is an auto-generated and fully type-safe database client" optional = false python-versions = ">=3.8.0" +groups = ["main"] files = [ {file = "prisma-0.15.0-py3-none-any.whl", hash = "sha256:de949cc94d3d91243615f22ff64490aa6e2d7cb81aabffce53d92bd3977c09a4"}, {file = "prisma-0.15.0.tar.gz", hash = "sha256:5cd6402aa8322625db3fc1152040404e7fc471fe7f8fa3a314fa8a99529ca107"}, @@ -2383,162 +4106,205 @@ typing-extensions = ">=4.5.0" all = ["nodejs-bin"] node = ["nodejs-bin"] +[[package]] +name = "proglog" +version = "0.1.12" +description = "Log and progress bar manager for console, notebooks, web..." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "proglog-0.1.12-py3-none-any.whl", hash = "sha256:ccaafce51e80a81c65dc907a460c07ccb8ec1f78dc660cfd8f9ec3a22f01b84c"}, + {file = "proglog-0.1.12.tar.gz", hash = "sha256:361ee074721c277b89b75c061336cb8c5f287c92b043efa562ccf7866cda931c"}, +] + +[package.dependencies] +tqdm = "*" + +[[package]] +name = "prometheus-client" +version = "0.22.1" +description = "Python client for the Prometheus monitoring system." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "prometheus_client-0.22.1-py3-none-any.whl", hash = "sha256:cca895342e308174341b2cbf99a56bef291fbc0ef7b9e5412a0f26d653ba7094"}, + {file = "prometheus_client-0.22.1.tar.gz", hash = "sha256:190f1331e783cf21eb60bca559354e0a4d4378facecf78f5428c39b675d20d28"}, +] + +[package.extras] +twisted = ["twisted"] + [[package]] name = "propcache" -version = "0.2.1" +version = "0.3.2" description = "Accelerated property cache" optional = false python-versions = ">=3.9" -files = [ - {file = "propcache-0.2.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6b3f39a85d671436ee3d12c017f8fdea38509e4f25b28eb25877293c98c243f6"}, - {file = "propcache-0.2.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d51fbe4285d5db5d92a929e3e21536ea3dd43732c5b177c7ef03f918dff9f2"}, - {file = "propcache-0.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6445804cf4ec763dc70de65a3b0d9954e868609e83850a47ca4f0cb64bd79fea"}, - {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f9479aa06a793c5aeba49ce5c5692ffb51fcd9a7016e017d555d5e2b0045d212"}, - {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d9631c5e8b5b3a0fda99cb0d29c18133bca1e18aea9effe55adb3da1adef80d3"}, - {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3156628250f46a0895f1f36e1d4fbe062a1af8718ec3ebeb746f1d23f0c5dc4d"}, - {file = "propcache-0.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b6fb63ae352e13748289f04f37868099e69dba4c2b3e271c46061e82c745634"}, - {file = "propcache-0.2.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:887d9b0a65404929641a9fabb6452b07fe4572b269d901d622d8a34a4e9043b2"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a96dc1fa45bd8c407a0af03b2d5218392729e1822b0c32e62c5bf7eeb5fb3958"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a7e65eb5c003a303b94aa2c3852ef130230ec79e349632d030e9571b87c4698c"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:999779addc413181912e984b942fbcc951be1f5b3663cd80b2687758f434c583"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:19a0f89a7bb9d8048d9c4370c9c543c396e894c76be5525f5e1ad287f1750ddf"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1ac2f5fe02fa75f56e1ad473f1175e11f475606ec9bd0be2e78e4734ad575034"}, - {file = "propcache-0.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:574faa3b79e8ebac7cb1d7930f51184ba1ccf69adfdec53a12f319a06030a68b"}, - {file = "propcache-0.2.1-cp310-cp310-win32.whl", hash = "sha256:03ff9d3f665769b2a85e6157ac8b439644f2d7fd17615a82fa55739bc97863f4"}, - {file = "propcache-0.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:2d3af2e79991102678f53e0dbf4c35de99b6b8b58f29a27ca0325816364caaba"}, - {file = "propcache-0.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ffc3cca89bb438fb9c95c13fc874012f7b9466b89328c3c8b1aa93cdcfadd16"}, - {file = "propcache-0.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f174bbd484294ed9fdf09437f889f95807e5f229d5d93588d34e92106fbf6717"}, - {file = "propcache-0.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:70693319e0b8fd35dd863e3e29513875eb15c51945bf32519ef52927ca883bc3"}, - {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b480c6a4e1138e1aa137c0079b9b6305ec6dcc1098a8ca5196283e8a49df95a9"}, - {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d27b84d5880f6d8aa9ae3edb253c59d9f6642ffbb2c889b78b60361eed449787"}, - {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:857112b22acd417c40fa4595db2fe28ab900c8c5fe4670c7989b1c0230955465"}, - {file = "propcache-0.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf6c4150f8c0e32d241436526f3c3f9cbd34429492abddbada2ffcff506c51af"}, - {file = "propcache-0.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66d4cfda1d8ed687daa4bc0274fcfd5267873db9a5bc0418c2da19273040eeb7"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2f992c07c0fca81655066705beae35fc95a2fa7366467366db627d9f2ee097f"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:4a571d97dbe66ef38e472703067021b1467025ec85707d57e78711c085984e54"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bb6178c241278d5fe853b3de743087be7f5f4c6f7d6d22a3b524d323eecec505"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ad1af54a62ffe39cf34db1aa6ed1a1873bd548f6401db39d8e7cd060b9211f82"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e7048abd75fe40712005bcfc06bb44b9dfcd8e101dda2ecf2f5aa46115ad07ca"}, - {file = "propcache-0.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:160291c60081f23ee43d44b08a7e5fb76681221a8e10b3139618c5a9a291b84e"}, - {file = "propcache-0.2.1-cp311-cp311-win32.whl", hash = "sha256:819ce3b883b7576ca28da3861c7e1a88afd08cc8c96908e08a3f4dd64a228034"}, - {file = "propcache-0.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:edc9fc7051e3350643ad929df55c451899bb9ae6d24998a949d2e4c87fb596d3"}, - {file = "propcache-0.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:081a430aa8d5e8876c6909b67bd2d937bfd531b0382d3fdedb82612c618bc41a"}, - {file = "propcache-0.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2ccec9ac47cf4e04897619c0e0c1a48c54a71bdf045117d3a26f80d38ab1fb0"}, - {file = "propcache-0.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:14d86fe14b7e04fa306e0c43cdbeebe6b2c2156a0c9ce56b815faacc193e320d"}, - {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:049324ee97bb67285b49632132db351b41e77833678432be52bdd0289c0e05e4"}, - {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1cd9a1d071158de1cc1c71a26014dcdfa7dd3d5f4f88c298c7f90ad6f27bb46d"}, - {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98110aa363f1bb4c073e8dcfaefd3a5cea0f0834c2aab23dda657e4dab2f53b5"}, - {file = "propcache-0.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:647894f5ae99c4cf6bb82a1bb3a796f6e06af3caa3d32e26d2350d0e3e3faf24"}, - {file = "propcache-0.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfd3223c15bebe26518d58ccf9a39b93948d3dcb3e57a20480dfdd315356baff"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d71264a80f3fcf512eb4f18f59423fe82d6e346ee97b90625f283df56aee103f"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e73091191e4280403bde6c9a52a6999d69cdfde498f1fdf629105247599b57ec"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3935bfa5fede35fb202c4b569bb9c042f337ca4ff7bd540a0aa5e37131659348"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f508b0491767bb1f2b87fdfacaba5f7eddc2f867740ec69ece6d1946d29029a6"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1672137af7c46662a1c2be1e8dc78cb6d224319aaa40271c9257d886be4363a6"}, - {file = "propcache-0.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b74c261802d3d2b85c9df2dfb2fa81b6f90deeef63c2db9f0e029a3cac50b518"}, - {file = "propcache-0.2.1-cp312-cp312-win32.whl", hash = "sha256:d09c333d36c1409d56a9d29b3a1b800a42c76a57a5a8907eacdbce3f18768246"}, - {file = "propcache-0.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:c214999039d4f2a5b2073ac506bba279945233da8c786e490d411dfc30f855c1"}, - {file = "propcache-0.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aca405706e0b0a44cc6bfd41fbe89919a6a56999157f6de7e182a990c36e37bc"}, - {file = "propcache-0.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:12d1083f001ace206fe34b6bdc2cb94be66d57a850866f0b908972f90996b3e9"}, - {file = "propcache-0.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d93f3307ad32a27bda2e88ec81134b823c240aa3abb55821a8da553eed8d9439"}, - {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba278acf14471d36316159c94a802933d10b6a1e117b8554fe0d0d9b75c9d536"}, - {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4e6281aedfca15301c41f74d7005e6e3f4ca143584ba696ac69df4f02f40d629"}, - {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b750a8e5a1262434fb1517ddf64b5de58327f1adc3524a5e44c2ca43305eb0b"}, - {file = "propcache-0.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf72af5e0fb40e9babf594308911436c8efde3cb5e75b6f206c34ad18be5c052"}, - {file = "propcache-0.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2d0a12018b04f4cb820781ec0dffb5f7c7c1d2a5cd22bff7fb055a2cb19ebce"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e800776a79a5aabdb17dcc2346a7d66d0777e942e4cd251defeb084762ecd17d"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4160d9283bd382fa6c0c2b5e017acc95bc183570cd70968b9202ad6d8fc48dce"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b43e74f1359353341a7adb783c8f1b1c676367b011709f466f42fda2045e95"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:58791550b27d5488b1bb52bc96328456095d96206a250d28d874fafe11b3dfaf"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0f022d381747f0dfe27e99d928e31bc51a18b65bb9e481ae0af1380a6725dd1f"}, - {file = "propcache-0.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:297878dc9d0a334358f9b608b56d02e72899f3b8499fc6044133f0d319e2ec30"}, - {file = "propcache-0.2.1-cp313-cp313-win32.whl", hash = "sha256:ddfab44e4489bd79bda09d84c430677fc7f0a4939a73d2bba3073036f487a0a6"}, - {file = "propcache-0.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:556fc6c10989f19a179e4321e5d678db8eb2924131e64652a51fe83e4c3db0e1"}, - {file = "propcache-0.2.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:6a9a8c34fb7bb609419a211e59da8887eeca40d300b5ea8e56af98f6fbbb1541"}, - {file = "propcache-0.2.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ae1aa1cd222c6d205853b3013c69cd04515f9d6ab6de4b0603e2e1c33221303e"}, - {file = "propcache-0.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:accb6150ce61c9c4b7738d45550806aa2b71c7668c6942f17b0ac182b6142fd4"}, - {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5eee736daafa7af6d0a2dc15cc75e05c64f37fc37bafef2e00d77c14171c2097"}, - {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7a31fc1e1bd362874863fdeed71aed92d348f5336fd84f2197ba40c59f061bd"}, - {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba4cfa1052819d16699e1d55d18c92b6e094d4517c41dd231a8b9f87b6fa681"}, - {file = "propcache-0.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f089118d584e859c62b3da0892b88a83d611c2033ac410e929cb6754eec0ed16"}, - {file = "propcache-0.2.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:781e65134efaf88feb447e8c97a51772aa75e48b794352f94cb7ea717dedda0d"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:31f5af773530fd3c658b32b6bdc2d0838543de70eb9a2156c03e410f7b0d3aae"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:a7a078f5d37bee6690959c813977da5291b24286e7b962e62a94cec31aa5188b"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cea7daf9fc7ae6687cf1e2c049752f19f146fdc37c2cc376e7d0032cf4f25347"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:8b3489ff1ed1e8315674d0775dc7d2195fb13ca17b3808721b54dbe9fd020faf"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:9403db39be1393618dd80c746cb22ccda168efce239c73af13c3763ef56ffc04"}, - {file = "propcache-0.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5d97151bc92d2b2578ff7ce779cdb9174337390a535953cbb9452fb65164c587"}, - {file = "propcache-0.2.1-cp39-cp39-win32.whl", hash = "sha256:9caac6b54914bdf41bcc91e7eb9147d331d29235a7c967c150ef5df6464fd1bb"}, - {file = "propcache-0.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:92fc4500fcb33899b05ba73276dfb684a20d31caa567b7cb5252d48f896a91b1"}, - {file = "propcache-0.2.1-py3-none-any.whl", hash = "sha256:52277518d6aae65536e9cea52d4e7fd2f7a66f4aa2d30ed3f2fcea620ace3c54"}, - {file = "propcache-0.2.1.tar.gz", hash = "sha256:3f77ce728b19cb537714499928fe800c3dda29e8d9428778fc7c186da4c09a64"}, +groups = ["main"] +files = [ + {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:22d9962a358aedbb7a2e36187ff273adeaab9743373a272976d2e348d08c7770"}, + {file = "propcache-0.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0d0fda578d1dc3f77b6b5a5dce3b9ad69a8250a891760a548df850a5e8da87f3"}, + {file = "propcache-0.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3def3da3ac3ce41562d85db655d18ebac740cb3fa4367f11a52b3da9d03a5cc3"}, + {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bec58347a5a6cebf239daba9bda37dffec5b8d2ce004d9fe4edef3d2815137e"}, + {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55ffda449a507e9fbd4aca1a7d9aa6753b07d6166140e5a18d2ac9bc49eac220"}, + {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64a67fb39229a8a8491dd42f864e5e263155e729c2e7ff723d6e25f596b1e8cb"}, + {file = "propcache-0.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da1cf97b92b51253d5b68cf5a2b9e0dafca095e36b7f2da335e27dc6172a614"}, + {file = "propcache-0.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5f559e127134b07425134b4065be45b166183fdcb433cb6c24c8e4149056ad50"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:aff2e4e06435d61f11a428360a932138d0ec288b0a31dd9bd78d200bd4a2b339"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:4927842833830942a5d0a56e6f4839bc484785b8e1ce8d287359794818633ba0"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:6107ddd08b02654a30fb8ad7a132021759d750a82578b94cd55ee2772b6ebea2"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:70bd8b9cd6b519e12859c99f3fc9a93f375ebd22a50296c3a295028bea73b9e7"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:2183111651d710d3097338dd1893fcf09c9f54e27ff1a8795495a16a469cc90b"}, + {file = "propcache-0.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fb075ad271405dcad8e2a7ffc9a750a3bf70e533bd86e89f0603e607b93aa64c"}, + {file = "propcache-0.3.2-cp310-cp310-win32.whl", hash = "sha256:404d70768080d3d3bdb41d0771037da19d8340d50b08e104ca0e7f9ce55fce70"}, + {file = "propcache-0.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:7435d766f978b4ede777002e6b3b6641dd229cd1da8d3d3106a45770365f9ad9"}, + {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0b8d2f607bd8f80ddc04088bc2a037fdd17884a6fcadc47a96e334d72f3717be"}, + {file = "propcache-0.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:06766d8f34733416e2e34f46fea488ad5d60726bb9481d3cddf89a6fa2d9603f"}, + {file = "propcache-0.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2dc1f4a1df4fecf4e6f68013575ff4af84ef6f478fe5344317a65d38a8e6dc9"}, + {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be29c4f4810c5789cf10ddf6af80b041c724e629fa51e308a7a0fb19ed1ef7bf"}, + {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59d61f6970ecbd8ff2e9360304d5c8876a6abd4530cb752c06586849ac8a9dc9"}, + {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62180e0b8dbb6b004baec00a7983e4cc52f5ada9cd11f48c3528d8cfa7b96a66"}, + {file = "propcache-0.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c144ca294a204c470f18cf4c9d78887810d04a3e2fbb30eea903575a779159df"}, + {file = "propcache-0.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c5c2a784234c28854878d68978265617aa6dc0780e53d44b4d67f3651a17a9a2"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5745bc7acdafa978ca1642891b82c19238eadc78ba2aaa293c6863b304e552d7"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c0075bf773d66fa8c9d41f66cc132ecc75e5bb9dd7cce3cfd14adc5ca184cb95"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5f57aa0847730daceff0497f417c9de353c575d8da3579162cc74ac294c5369e"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:eef914c014bf72d18efb55619447e0aecd5fb7c2e3fa7441e2e5d6099bddff7e"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2a4092e8549031e82facf3decdbc0883755d5bbcc62d3aea9d9e185549936dcf"}, + {file = "propcache-0.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85871b050f174bc0bfb437efbdb68aaf860611953ed12418e4361bc9c392749e"}, + {file = "propcache-0.3.2-cp311-cp311-win32.whl", hash = "sha256:36c8d9b673ec57900c3554264e630d45980fd302458e4ac801802a7fd2ef7897"}, + {file = "propcache-0.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:e53af8cb6a781b02d2ea079b5b853ba9430fcbe18a8e3ce647d5982a3ff69f39"}, + {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8de106b6c84506b31c27168582cd3cb3000a6412c16df14a8628e5871ff83c10"}, + {file = "propcache-0.3.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:28710b0d3975117239c76600ea351934ac7b5ff56e60953474342608dbbb6154"}, + {file = "propcache-0.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce26862344bdf836650ed2487c3d724b00fbfec4233a1013f597b78c1cb73615"}, + {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bca54bd347a253af2cf4544bbec232ab982f4868de0dd684246b67a51bc6b1db"}, + {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:55780d5e9a2ddc59711d727226bb1ba83a22dd32f64ee15594b9392b1f544eb1"}, + {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:035e631be25d6975ed87ab23153db6a73426a48db688070d925aa27e996fe93c"}, + {file = "propcache-0.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee6f22b6eaa39297c751d0e80c0d3a454f112f5c6481214fcf4c092074cecd67"}, + {file = "propcache-0.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ca3aee1aa955438c4dba34fc20a9f390e4c79967257d830f137bd5a8a32ed3b"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7a4f30862869fa2b68380d677cc1c5fcf1e0f2b9ea0cf665812895c75d0ca3b8"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b77ec3c257d7816d9f3700013639db7491a434644c906a2578a11daf13176251"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cab90ac9d3f14b2d5050928483d3d3b8fb6b4018893fc75710e6aa361ecb2474"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:0b504d29f3c47cf6b9e936c1852246c83d450e8e063d50562115a6be6d3a2535"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:ce2ac2675a6aa41ddb2a0c9cbff53780a617ac3d43e620f8fd77ba1c84dcfc06"}, + {file = "propcache-0.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b4239611205294cc433845b914131b2a1f03500ff3c1ed093ed216b82621e1"}, + {file = "propcache-0.3.2-cp312-cp312-win32.whl", hash = "sha256:df4a81b9b53449ebc90cc4deefb052c1dd934ba85012aa912c7ea7b7e38b60c1"}, + {file = "propcache-0.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:7046e79b989d7fe457bb755844019e10f693752d169076138abf17f31380800c"}, + {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ca592ed634a73ca002967458187109265e980422116c0a107cf93d81f95af945"}, + {file = "propcache-0.3.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9ecb0aad4020e275652ba3975740f241bd12a61f1a784df044cf7477a02bc252"}, + {file = "propcache-0.3.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7f08f1cc28bd2eade7a8a3d2954ccc673bb02062e3e7da09bc75d843386b342f"}, + {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1a342c834734edb4be5ecb1e9fb48cb64b1e2320fccbd8c54bf8da8f2a84c33"}, + {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8a544caaae1ac73f1fecfae70ded3e93728831affebd017d53449e3ac052ac1e"}, + {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:310d11aa44635298397db47a3ebce7db99a4cc4b9bbdfcf6c98a60c8d5261cf1"}, + {file = "propcache-0.3.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4c1396592321ac83157ac03a2023aa6cc4a3cc3cfdecb71090054c09e5a7cce3"}, + {file = "propcache-0.3.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8cabf5b5902272565e78197edb682017d21cf3b550ba0460ee473753f28d23c1"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0a2f2235ac46a7aa25bdeb03a9e7060f6ecbd213b1f9101c43b3090ffb971ef6"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:92b69e12e34869a6970fd2f3da91669899994b47c98f5d430b781c26f1d9f387"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:54e02207c79968ebbdffc169591009f4474dde3b4679e16634d34c9363ff56b4"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4adfb44cb588001f68c5466579d3f1157ca07f7504fc91ec87862e2b8e556b88"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fd3e6019dc1261cd0291ee8919dd91fbab7b169bb76aeef6c716833a3f65d206"}, + {file = "propcache-0.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4c181cad81158d71c41a2bce88edce078458e2dd5ffee7eddd6b05da85079f43"}, + {file = "propcache-0.3.2-cp313-cp313-win32.whl", hash = "sha256:8a08154613f2249519e549de2330cf8e2071c2887309a7b07fb56098f5170a02"}, + {file = "propcache-0.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:e41671f1594fc4ab0a6dec1351864713cb3a279910ae8b58f884a88a0a632c05"}, + {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:9a3cf035bbaf035f109987d9d55dc90e4b0e36e04bbbb95af3055ef17194057b"}, + {file = "propcache-0.3.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:156c03d07dc1323d8dacaa221fbe028c5c70d16709cdd63502778e6c3ccca1b0"}, + {file = "propcache-0.3.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:74413c0ba02ba86f55cf60d18daab219f7e531620c15f1e23d95563f505efe7e"}, + {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f066b437bb3fa39c58ff97ab2ca351db465157d68ed0440abecb21715eb24b28"}, + {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f1304b085c83067914721e7e9d9917d41ad87696bf70f0bc7dee450e9c71ad0a"}, + {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ab50cef01b372763a13333b4e54021bdcb291fc9a8e2ccb9c2df98be51bcde6c"}, + {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fad3b2a085ec259ad2c2842666b2a0a49dea8463579c606426128925af1ed725"}, + {file = "propcache-0.3.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:261fa020c1c14deafd54c76b014956e2f86991af198c51139faf41c4d5e83892"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:46d7f8aa79c927e5f987ee3a80205c987717d3659f035c85cf0c3680526bdb44"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:6d8f3f0eebf73e3c0ff0e7853f68be638b4043c65a70517bb575eff54edd8dbe"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:03c89c1b14a5452cf15403e291c0ccd7751d5b9736ecb2c5bab977ad6c5bcd81"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:0cc17efde71e12bbaad086d679ce575268d70bc123a5a71ea7ad76f70ba30bba"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:acdf05d00696bc0447e278bb53cb04ca72354e562cf88ea6f9107df8e7fd9770"}, + {file = "propcache-0.3.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4445542398bd0b5d32df908031cb1b30d43ac848e20470a878b770ec2dcc6330"}, + {file = "propcache-0.3.2-cp313-cp313t-win32.whl", hash = "sha256:f86e5d7cd03afb3a1db8e9f9f6eff15794e79e791350ac48a8c924e6f439f394"}, + {file = "propcache-0.3.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9704bedf6e7cbe3c65eca4379a9b53ee6a83749f047808cbb5044d40d7d72198"}, + {file = "propcache-0.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a7fad897f14d92086d6b03fdd2eb844777b0c4d7ec5e3bac0fbae2ab0602bbe5"}, + {file = "propcache-0.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:1f43837d4ca000243fd7fd6301947d7cb93360d03cd08369969450cc6b2ce3b4"}, + {file = "propcache-0.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:261df2e9474a5949c46e962065d88eb9b96ce0f2bd30e9d3136bcde84befd8f2"}, + {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e514326b79e51f0a177daab1052bc164d9d9e54133797a3a58d24c9c87a3fe6d"}, + {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4a996adb6904f85894570301939afeee65f072b4fd265ed7e569e8d9058e4ec"}, + {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:76cace5d6b2a54e55b137669b30f31aa15977eeed390c7cbfb1dafa8dfe9a701"}, + {file = "propcache-0.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31248e44b81d59d6addbb182c4720f90b44e1efdc19f58112a3c3a1615fb47ef"}, + {file = "propcache-0.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abb7fa19dbf88d3857363e0493b999b8011eea856b846305d8c0512dfdf8fbb1"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:d81ac3ae39d38588ad0549e321e6f773a4e7cc68e7751524a22885d5bbadf886"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:cc2782eb0f7a16462285b6f8394bbbd0e1ee5f928034e941ffc444012224171b"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:db429c19a6c7e8a1c320e6a13c99799450f411b02251fb1b75e6217cf4a14fcb"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:21d8759141a9e00a681d35a1f160892a36fb6caa715ba0b832f7747da48fb6ea"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2ca6d378f09adb13837614ad2754fa8afaee330254f404299611bce41a8438cb"}, + {file = "propcache-0.3.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:34a624af06c048946709f4278b4176470073deda88d91342665d95f7c6270fbe"}, + {file = "propcache-0.3.2-cp39-cp39-win32.whl", hash = "sha256:4ba3fef1c30f306b1c274ce0b8baaa2c3cdd91f645c48f06394068f37d3837a1"}, + {file = "propcache-0.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:7a2368eed65fc69a7a7a40b27f22e85e7627b74216f0846b04ba5c116e191ec9"}, + {file = "propcache-0.3.2-py3-none-any.whl", hash = "sha256:98f1ec44fb675f5052cccc8e609c46ed23a35a1cfd18545ad4e29002d858a43f"}, + {file = "propcache-0.3.2.tar.gz", hash = "sha256:20d7d62e4e7ef05f221e0db2856b979540686342e7dd9973b815599c7057e168"}, ] [[package]] name = "proto-plus" -version = "1.25.0" -description = "Beautiful, Pythonic protocol buffers." +version = "1.26.1" +description = "Beautiful, Pythonic protocol buffers" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "proto_plus-1.25.0-py3-none-any.whl", hash = "sha256:c91fc4a65074ade8e458e95ef8bac34d4008daa7cce4a12d6707066fca648961"}, - {file = "proto_plus-1.25.0.tar.gz", hash = "sha256:fbb17f57f7bd05a68b7707e745e26528b0b3c34e378db91eef93912c54982d91"}, + {file = "proto_plus-1.26.1-py3-none-any.whl", hash = "sha256:13285478c2dcf2abb829db158e1047e2f1e8d63a077d94263c2b88b043c75a66"}, + {file = "proto_plus-1.26.1.tar.gz", hash = "sha256:21a515a4c4c0088a773899e23c7bbade3d18f9c66c73edd4c7ee3816bc96a012"}, ] [package.dependencies] -protobuf = ">=3.19.0,<6.0.0dev" +protobuf = ">=3.19.0,<7.0.0" [package.extras] testing = ["google-api-core (>=1.31.5)"] [[package]] name = "protobuf" -version = "5.29.1" +version = "5.29.5" description = "" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "protobuf-5.29.1-cp310-abi3-win32.whl", hash = "sha256:22c1f539024241ee545cbcb00ee160ad1877975690b16656ff87dde107b5f110"}, - {file = "protobuf-5.29.1-cp310-abi3-win_amd64.whl", hash = "sha256:1fc55267f086dd4050d18ef839d7bd69300d0d08c2a53ca7df3920cc271a3c34"}, - {file = "protobuf-5.29.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:d473655e29c0c4bbf8b69e9a8fb54645bc289dead6d753b952e7aa660254ae18"}, - {file = "protobuf-5.29.1-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:b5ba1d0e4c8a40ae0496d0e2ecfdbb82e1776928a205106d14ad6985a09ec155"}, - {file = "protobuf-5.29.1-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:8ee1461b3af56145aca2800e6a3e2f928108c749ba8feccc6f5dd0062c410c0d"}, - {file = "protobuf-5.29.1-cp38-cp38-win32.whl", hash = "sha256:50879eb0eb1246e3a5eabbbe566b44b10348939b7cc1b267567e8c3d07213853"}, - {file = "protobuf-5.29.1-cp38-cp38-win_amd64.whl", hash = "sha256:027fbcc48cea65a6b17028510fdd054147057fa78f4772eb547b9274e5219331"}, - {file = "protobuf-5.29.1-cp39-cp39-win32.whl", hash = "sha256:5a41deccfa5e745cef5c65a560c76ec0ed8e70908a67cc8f4da5fce588b50d57"}, - {file = "protobuf-5.29.1-cp39-cp39-win_amd64.whl", hash = "sha256:012ce28d862ff417fd629285aca5d9772807f15ceb1a0dbd15b88f58c776c98c"}, - {file = "protobuf-5.29.1-py3-none-any.whl", hash = "sha256:32600ddb9c2a53dedc25b8581ea0f1fd8ea04956373c0c07577ce58d312522e0"}, - {file = "protobuf-5.29.1.tar.gz", hash = "sha256:683be02ca21a6ffe80db6dd02c0b5b2892322c59ca57fd6c872d652cb80549cb"}, + {file = "protobuf-5.29.5-cp310-abi3-win32.whl", hash = "sha256:3f1c6468a2cfd102ff4703976138844f78ebd1fb45f49011afc5139e9e283079"}, + {file = "protobuf-5.29.5-cp310-abi3-win_amd64.whl", hash = "sha256:3f76e3a3675b4a4d867b52e4a5f5b78a2ef9565549d4037e06cf7b0942b1d3fc"}, + {file = "protobuf-5.29.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e38c5add5a311f2a6eb0340716ef9b039c1dfa428b28f25a7838ac329204a671"}, + {file = "protobuf-5.29.5-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:fa18533a299d7ab6c55a238bf8629311439995f2e7eca5caaff08663606e9015"}, + {file = "protobuf-5.29.5-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:63848923da3325e1bf7e9003d680ce6e14b07e55d0473253a690c3a8b8fd6e61"}, + {file = "protobuf-5.29.5-cp38-cp38-win32.whl", hash = "sha256:ef91363ad4faba7b25d844ef1ada59ff1604184c0bcd8b39b8a6bef15e1af238"}, + {file = "protobuf-5.29.5-cp38-cp38-win_amd64.whl", hash = "sha256:7318608d56b6402d2ea7704ff1e1e4597bee46d760e7e4dd42a3d45e24b87f2e"}, + {file = "protobuf-5.29.5-cp39-cp39-win32.whl", hash = "sha256:6f642dc9a61782fa72b90878af134c5afe1917c89a568cd3476d758d3c3a0736"}, + {file = "protobuf-5.29.5-cp39-cp39-win_amd64.whl", hash = "sha256:470f3af547ef17847a28e1f47200a1cbf0ba3ff57b7de50d22776607cd2ea353"}, + {file = "protobuf-5.29.5-py3-none-any.whl", hash = "sha256:6cf42630262c59b2d8de33954443d94b746c952b01434fc58a417fdbd2e84bd5"}, + {file = "protobuf-5.29.5.tar.gz", hash = "sha256:bc1463bafd4b0929216c35f437a8e28731a2b7fe3d98bb77a600efced5a15c84"}, ] [[package]] name = "psutil" -version = "6.1.0" -description = "Cross-platform lib for process and system monitoring in Python." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7" -files = [ - {file = "psutil-6.1.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ff34df86226c0227c52f38b919213157588a678d049688eded74c76c8ba4a5d0"}, - {file = "psutil-6.1.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:c0e0c00aa18ca2d3b2b991643b799a15fc8f0563d2ebb6040f64ce8dc027b942"}, - {file = "psutil-6.1.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:000d1d1ebd634b4efb383f4034437384e44a6d455260aaee2eca1e9c1b55f047"}, - {file = "psutil-6.1.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:5cd2bcdc75b452ba2e10f0e8ecc0b57b827dd5d7aaffbc6821b2a9a242823a76"}, - {file = "psutil-6.1.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:045f00a43c737f960d273a83973b2511430d61f283a44c96bf13a6e829ba8fdc"}, - {file = "psutil-6.1.0-cp27-none-win32.whl", hash = "sha256:9118f27452b70bb1d9ab3198c1f626c2499384935aaf55388211ad982611407e"}, - {file = "psutil-6.1.0-cp27-none-win_amd64.whl", hash = "sha256:a8506f6119cff7015678e2bce904a4da21025cc70ad283a53b099e7620061d85"}, - {file = "psutil-6.1.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:6e2dcd475ce8b80522e51d923d10c7871e45f20918e027ab682f94f1c6351688"}, - {file = "psutil-6.1.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0895b8414afafc526712c498bd9de2b063deaac4021a3b3c34566283464aff8e"}, - {file = "psutil-6.1.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9dcbfce5d89f1d1f2546a2090f4fcf87c7f669d1d90aacb7d7582addece9fb38"}, - {file = "psutil-6.1.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:498c6979f9c6637ebc3a73b3f87f9eb1ec24e1ce53a7c5173b8508981614a90b"}, - {file = "psutil-6.1.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d905186d647b16755a800e7263d43df08b790d709d575105d419f8b6ef65423a"}, - {file = "psutil-6.1.0-cp36-cp36m-win32.whl", hash = "sha256:6d3fbbc8d23fcdcb500d2c9f94e07b1342df8ed71b948a2649b5cb060a7c94ca"}, - {file = "psutil-6.1.0-cp36-cp36m-win_amd64.whl", hash = "sha256:1209036fbd0421afde505a4879dee3b2fd7b1e14fee81c0069807adcbbcca747"}, - {file = "psutil-6.1.0-cp37-abi3-win32.whl", hash = "sha256:1ad45a1f5d0b608253b11508f80940985d1d0c8f6111b5cb637533a0e6ddc13e"}, - {file = "psutil-6.1.0-cp37-abi3-win_amd64.whl", hash = "sha256:a8fb3752b491d246034fa4d279ff076501588ce8cbcdbb62c32fd7a377d996be"}, - {file = "psutil-6.1.0.tar.gz", hash = "sha256:353815f59a7f64cdaca1c0307ee13558a0512f6db064e92fe833784f08539c7a"}, +version = "7.0.0" +description = "Cross-platform lib for process and system monitoring in Python. NOTE: the syntax of this script MUST be kept compatible with Python 2.7." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25"}, + {file = "psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da"}, + {file = "psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91"}, + {file = "psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34"}, + {file = "psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993"}, + {file = "psutil-7.0.0-cp36-cp36m-win32.whl", hash = "sha256:84df4eb63e16849689f76b1ffcb36db7b8de703d1bc1fe41773db487621b6c17"}, + {file = "psutil-7.0.0-cp36-cp36m-win_amd64.whl", hash = "sha256:1e744154a6580bc968a0195fd25e80432d3afec619daf145b9e5ba16cc1d688e"}, + {file = "psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99"}, + {file = "psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553"}, + {file = "psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456"}, ] [package.extras] -dev = ["black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "wheel"] +dev = ["abi3audit", "black (==24.10.0)", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest", "pytest-cov", "pytest-xdist", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"] test = ["pytest", "pytest-xdist", "setuptools"] [[package]] @@ -2547,6 +4313,7 @@ version = "2.9.10" description = "psycopg2 - Python-PostgreSQL Database Adapter" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "psycopg2-binary-2.9.10.tar.gz", hash = "sha256:4b3df0e6990aa98acda57d983942eff13d824135fe2250e6522edaa782a06de2"}, {file = "psycopg2_binary-2.9.10-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:0ea8e3d0ae83564f2fc554955d327fa081d065c8ca5cc6d2abb643e2c9c1200f"}, @@ -2595,6 +4362,7 @@ files = [ {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, + {file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, @@ -2623,6 +4391,7 @@ version = "0.6.1" description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629"}, {file = "pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034"}, @@ -2634,6 +4403,7 @@ version = "0.4.1" description = "A collection of ASN.1-based protocols modules" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd"}, {file = "pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c"}, @@ -2642,15 +4412,108 @@ files = [ [package.dependencies] pyasn1 = ">=0.4.6,<0.7.0" +[[package]] +name = "pycares" +version = "4.9.0" +description = "Python interface for c-ares" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pycares-4.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b8bd9a3ee6e9bc990e1933dc7e7e2f44d4184f49a90fa444297ac12ab6c0c84"}, + {file = "pycares-4.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:417a5c20861f35977240ad4961479a6778125bcac21eb2ad1c3aad47e2ff7fab"}, + {file = "pycares-4.9.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab290faa4ea53ce53e3ceea1b3a42822daffce2d260005533293a52525076750"}, + {file = "pycares-4.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b1df81193084c9717734e4615e8c5074b9852478c9007d1a8bb242f7f580e67"}, + {file = "pycares-4.9.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20c7a6af0c2ccd17cc5a70d76e299a90e7ebd6c4d8a3d7fff5ae533339f61431"}, + {file = "pycares-4.9.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:370f41442a5b034aebdb2719b04ee04d3e805454a20d3f64f688c1c49f9137c3"}, + {file = "pycares-4.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:340e4a3bbfd14d73c01ec0793a321b8a4a93f64c508225883291078b7ee17ac8"}, + {file = "pycares-4.9.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f0ec94785856ea4f5556aa18f4c027361ba4b26cb36c4ad97d2105ef4eec68ba"}, + {file = "pycares-4.9.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd6b7e23a4a9e2039b5d67dfa0499d2d5f114667dc13fb5d7d03eed230c7ac4f"}, + {file = "pycares-4.9.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:490c978b0be9d35a253a5e31dd598f6d66b453625f0eb7dc2d81b22b8c3bb3f4"}, + {file = "pycares-4.9.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e433faaf07f44e44f1a1b839fee847480fe3db9431509dafc9f16d618d491d0f"}, + {file = "pycares-4.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf6d8851a06b79d10089962c9dadcb34dad00bf027af000f7102297a54aaff2e"}, + {file = "pycares-4.9.0-cp310-cp310-win32.whl", hash = "sha256:4f803e7d66ac7d8342998b8b07393788991353a46b05bbaad0b253d6f3484ea8"}, + {file = "pycares-4.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e17bd32267e3870855de3baed7d0efa6337344d68f44853fd9195c919f39400"}, + {file = "pycares-4.9.0-cp310-cp310-win_arm64.whl", hash = "sha256:6b74f75d8e430f9bb11a1cc99b2e328eed74b17d8d4b476de09126f38d419eb9"}, + {file = "pycares-4.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:16a97ee83ec60d35c7f716f117719932c27d428b1bb56b242ba1c4aa55521747"}, + {file = "pycares-4.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:78748521423a211ce699a50c27cc5c19e98b7db610ccea98daad652ace373990"}, + {file = "pycares-4.9.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8818b2c7a57d9d6d41e8b64d9ff87992b8ea2522fc0799686725228bc3cff6c5"}, + {file = "pycares-4.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96df8990f16013ca5194d6ece19dddb4ef9cd7c3efaab9f196ec3ccd44b40f8d"}, + {file = "pycares-4.9.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:61af86fd58b8326e723b0d20fb96b56acaec2261c3a7c9a1c29d0a79659d613a"}, + {file = "pycares-4.9.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec72edb276bda559813cc807bc47b423d409ffab2402417a5381077e9c2c6be1"}, + {file = "pycares-4.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832fb122c7376c76cab62f8862fa5e398b9575fb7c9ff6bc9811086441ee64ca"}, + {file = "pycares-4.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cdcfaef24f771a471671470ccfd676c0366ab6b0616fd8217b8f356c40a02b83"}, + {file = "pycares-4.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:52cb056d06ff55d78a8665b97ae948abaaba2ca200ca59b10346d4526bce1e7d"}, + {file = "pycares-4.9.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:54985ed3f2e8a87315269f24cb73441622857a7830adfc3a27c675a94c3261c1"}, + {file = "pycares-4.9.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:08048e223615d4aef3dac81fe0ea18fb18d6fc97881f1eb5be95bb1379969b8d"}, + {file = "pycares-4.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cc60037421ce05a409484287b2cd428e1363cca73c999b5f119936bb8f255208"}, + {file = "pycares-4.9.0-cp311-cp311-win32.whl", hash = "sha256:62b86895b60cfb91befb3086caa0792b53f949231c6c0c3053c7dfee3f1386ab"}, + {file = "pycares-4.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:7046b3c80954beaabf2db52b09c3d6fe85f6c4646af973e61be79d1c51589932"}, + {file = "pycares-4.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:fcbda3fdf44e94d3962ca74e6ba3dc18c0d7029106f030d61c04c0876f319403"}, + {file = "pycares-4.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d68ca2da1001aeccdc81c4a2fb1f1f6cfdafd3d00e44e7c1ed93e3e05437f666"}, + {file = "pycares-4.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4f0c8fa5a384d79551a27eafa39eed29529e66ba8fa795ee432ab88d050432a3"}, + {file = "pycares-4.9.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0eb8c428cf3b9c6ff9c641ba50ab6357b4480cd737498733e6169b0ac8a1a89b"}, + {file = "pycares-4.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6845bd4a43abf6dab7fedbf024ef458ac3750a25b25076ea9913e5ac5fec4548"}, + {file = "pycares-4.9.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5e28f4acc3b97e46610cf164665ebf914f709daea6ced0ca4358ce55bc1c3d6b"}, + {file = "pycares-4.9.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9464a39861840ce35a79352c34d653a9db44f9333af7c9feddb97998d3e00c07"}, + {file = "pycares-4.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e0611c1bd46d1fc6bdd9305b8850eb84c77df485769f72c574ed7b8389dfbee2"}, + {file = "pycares-4.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d4fb5a38a51d03b75ac4320357e632c2e72e03fdeb13263ee333a40621415fdc"}, + {file = "pycares-4.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:df5edae05fb3e1370ab7639e67e8891fdaa9026cb10f05dbd57893713f7a9cfe"}, + {file = "pycares-4.9.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:397123ea53d261007bb0aa7e767ef238778f45026db40bed8196436da2cc73de"}, + {file = "pycares-4.9.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bb0d874d0b131b29894fd8a0f842be91ac21d50f90ec04cff4bb3f598464b523"}, + {file = "pycares-4.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:497cc03a61ec1585eb17d2cb086a29a6a67d24babf1e9be519b47222916a3b06"}, + {file = "pycares-4.9.0-cp312-cp312-win32.whl", hash = "sha256:b46e46313fdb5e82da15478652aac0fd15e1c9f33e08153bad845aa4007d6f84"}, + {file = "pycares-4.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:12547a06445777091605a7581da15a0da158058beb8a05a3ebbf7301fd1f58d4"}, + {file = "pycares-4.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:f1e10bf1e8eb80b08e5c828627dba1ebc4acd54803bd0a27d92b9063b6aa99d8"}, + {file = "pycares-4.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:574d815112a95ab09d75d0a9dc7dea737c06985e3125cf31c32ba6a3ed6ca006"}, + {file = "pycares-4.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50e5ab06361d59625a27a7ad93d27e067dc7c9f6aa529a07d691eb17f3b43605"}, + {file = "pycares-4.9.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:785f5fd11ff40237d9bc8afa441551bb449e2812c74334d1d10859569e07515c"}, + {file = "pycares-4.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e194a500e403eba89b91fb863c917495c5b3dfcd1ce0ee8dc3a6f99a1360e2fc"}, + {file = "pycares-4.9.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:112dd49cdec4e6150a8d95b197e8b6b7b4468a3170b30738ed9b248cb2240c04"}, + {file = "pycares-4.9.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:94aa3c2f3eb0aa69160137134775501f06c901188e722aac63d2a210d4084f99"}, + {file = "pycares-4.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b510d71255cf5a92ccc2643a553548fcb0623d6ed11c8c633b421d99d7fa4167"}, + {file = "pycares-4.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5c6aa30b1492b8130f7832bf95178642c710ce6b7ba610c2b17377f77177e3cd"}, + {file = "pycares-4.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e5767988e044faffe2aff6a76aa08df99a8b6ef2641be8b00ea16334ce5dea93"}, + {file = "pycares-4.9.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b9928a942820a82daa3207509eaba9e0fa9660756ac56667ec2e062815331fcb"}, + {file = "pycares-4.9.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:556c854174da76d544714cdfab10745ed5d4b99eec5899f7b13988cd26ff4763"}, + {file = "pycares-4.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d42e2202ca9aa9a0a9a6e43a4a4408bbe0311aaa44800fa27b8fd7f82b20152a"}, + {file = "pycares-4.9.0-cp313-cp313-win32.whl", hash = "sha256:cce8ef72c9ed4982c84114e6148a4e42e989d745de7862a0ad8b3f1cdc05def2"}, + {file = "pycares-4.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:318cdf24f826f1d2f0c5a988730bd597e1683296628c8f1be1a5b96643c284fe"}, + {file = "pycares-4.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:faa9de8e647ed06757a2c117b70a7645a755561def814da6aca0d766cf71a402"}, + {file = "pycares-4.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8310d27d68fa25be9781ce04d330f4860634a2ac34dd9265774b5f404679b41f"}, + {file = "pycares-4.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:99cf98452d3285307eec123049f2c9c50b109e06751b0727c6acefb6da30c6a0"}, + {file = "pycares-4.9.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ffd6e8c8250655504602b076f106653e085e6b1e15318013442558101aa4777"}, + {file = "pycares-4.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4065858d8c812159c9a55601fda73760d9e5e3300f7868d9e546eab1084f36c"}, + {file = "pycares-4.9.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91ee6818113faf9013945c2b54bcd6b123d0ac192ae3099cf4288cedaf2dbb25"}, + {file = "pycares-4.9.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:21f0602059ec11857ab7ad608c7ec8bc6f7a302c04559ec06d33e82f040585f8"}, + {file = "pycares-4.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e22e5b46ed9b12183091da56e4a5a20813b5436c4f13135d7a1c20a84027ca8a"}, + {file = "pycares-4.9.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:9eded8649867bfd7aea7589c5755eae4d37686272f6ed7a995da40890d02de71"}, + {file = "pycares-4.9.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f71d31cbbe066657a2536c98aad850724a9ab7b1cd2624f491832ae9667ea8e7"}, + {file = "pycares-4.9.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2b30945982ab4741f097efc5b0853051afc3c11df26996ed53a700c7575175af"}, + {file = "pycares-4.9.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:54a8f1f067d64810426491d33033f5353b54f35e5339126440ad4e6afbf3f149"}, + {file = "pycares-4.9.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:41556a269a192349e92eee953f62eddd867e9eddb27f444b261e2c1c4a4a9eff"}, + {file = "pycares-4.9.0-cp39-cp39-win32.whl", hash = "sha256:524d6c14eaa167ed098a4fe54856d1248fa20c296cdd6976f9c1b838ba32d014"}, + {file = "pycares-4.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:15f930c733d36aa487b4ad60413013bd811281b5ea4ca620070fa38505d84df4"}, + {file = "pycares-4.9.0-cp39-cp39-win_arm64.whl", hash = "sha256:79b7addb2a41267d46650ac0d9c4f3b3233b036f186b85606f7586881dfb4b69"}, + {file = "pycares-4.9.0.tar.gz", hash = "sha256:8ee484ddb23dbec4d88d14ed5b6d592c1960d2e93c385d5e52b6fad564d82395"}, +] + +[package.dependencies] +cffi = ">=1.5.0" + +[package.extras] +idna = ["idna (>=2.1)"] + [[package]] name = "pycodestyle" -version = "2.12.1" +version = "2.14.0" description = "Python style guide checker" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pycodestyle-2.12.1-py2.py3-none-any.whl", hash = "sha256:46f0fb92069a7c28ab7bb558f05bfc0110dac69a0cd23c61ea0040283a9d78b3"}, - {file = "pycodestyle-2.12.1.tar.gz", hash = "sha256:6838eae08bbce4f6accd5d5572075c63626a15ee3e6f842df996bf62f6d73521"}, + {file = "pycodestyle-2.14.0-py2.py3-none-any.whl", hash = "sha256:dd6bf7cb4ee77f8e016f9c8e74a35ddd9f67e1d5fd4184d86c3b98e07099f42d"}, + {file = "pycodestyle-2.14.0.tar.gz", hash = "sha256:c4b5b517d278089ff9d0abdec919cd97262a3367449ea1c8b49b91529167b783"}, ] [[package]] @@ -2659,6 +4522,7 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, @@ -2666,131 +4530,134 @@ files = [ [[package]] name = "pydantic" -version = "2.10.3" +version = "2.11.7" description = "Data validation using Python type hints" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d"}, - {file = "pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9"}, + {file = "pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b"}, + {file = "pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db"}, ] [package.dependencies] annotated-types = ">=0.6.0" -pydantic-core = "2.27.1" +email-validator = {version = ">=2.0.0", optional = true, markers = "extra == \"email\""} +pydantic-core = "2.33.2" typing-extensions = ">=4.12.2" +typing-inspection = ">=0.4.0" [package.extras] email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] [[package]] name = "pydantic-core" -version = "2.27.1" +version = "2.33.2" description = "Core functionality for Pydantic validation and serialization" optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a"}, - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206"}, - {file = "pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c"}, - {file = "pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc"}, - {file = "pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9"}, - {file = "pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5"}, - {file = "pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae"}, - {file = "pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c"}, - {file = "pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16"}, - {file = "pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23"}, - {file = "pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05"}, - {file = "pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337"}, - {file = "pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b"}, - {file = "pydantic_core-2.27.1-cp38-none-win32.whl", hash = "sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618"}, - {file = "pydantic_core-2.27.1-cp38-none-win_amd64.whl", hash = "sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131"}, - {file = "pydantic_core-2.27.1-cp39-none-win32.whl", hash = "sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3"}, - {file = "pydantic_core-2.27.1-cp39-none-win_amd64.whl", hash = "sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840"}, - {file = "pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235"}, +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2b3d326aaef0c0399d9afffeb6367d5e26ddc24d351dbc9c636840ac355dc5d8"}, + {file = "pydantic_core-2.33.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0e5b2671f05ba48b94cb90ce55d8bdcaaedb8ba00cc5359f6810fc918713983d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0069c9acc3f3981b9ff4cdfaf088e98d83440a4c7ea1bc07460af3d4dc22e72d"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d53b22f2032c42eaaf025f7c40c2e3b94568ae077a606f006d206a463bc69572"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0405262705a123b7ce9f0b92f123334d67b70fd1f20a9372b907ce1080c7ba02"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4b25d91e288e2c4e0662b8038a28c6a07eaac3e196cfc4ff69de4ea3db992a1b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6bdfe4b3789761f3bcb4b1ddf33355a71079858958e3a552f16d5af19768fef2"}, + {file = "pydantic_core-2.33.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:efec8db3266b76ef9607c2c4c419bdb06bf335ae433b80816089ea7585816f6a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:031c57d67ca86902726e0fae2214ce6770bbe2f710dc33063187a68744a5ecac"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:f8de619080e944347f5f20de29a975c2d815d9ddd8be9b9b7268e2e3ef68605a"}, + {file = "pydantic_core-2.33.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:73662edf539e72a9440129f231ed3757faab89630d291b784ca99237fb94db2b"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win32.whl", hash = "sha256:0a39979dcbb70998b0e505fb1556a1d550a0781463ce84ebf915ba293ccb7e22"}, + {file = "pydantic_core-2.33.2-cp310-cp310-win_amd64.whl", hash = "sha256:b0379a2b24882fef529ec3b4987cb5d003b9cda32256024e6fe1586ac45fc640"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:4c5b0a576fb381edd6d27f0a85915c6daf2f8138dc5c267a57c08a62900758c7"}, + {file = "pydantic_core-2.33.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e799c050df38a639db758c617ec771fd8fb7a5f8eaaa4b27b101f266b216a246"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dc46a01bf8d62f227d5ecee74178ffc448ff4e5197c756331f71efcc66dc980f"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a144d4f717285c6d9234a66778059f33a89096dfb9b39117663fd8413d582dcc"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:73cf6373c21bc80b2e0dc88444f41ae60b2f070ed02095754eb5a01df12256de"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3dc625f4aa79713512d1976fe9f0bc99f706a9dee21dfd1810b4bbbf228d0e8a"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b21b5549499972441da4758d662aeea93f1923f953e9cbaff14b8b9565aef"}, + {file = "pydantic_core-2.33.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bdc25f3681f7b78572699569514036afe3c243bc3059d3942624e936ec93450e"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fe5b32187cbc0c862ee201ad66c30cf218e5ed468ec8dc1cf49dec66e160cc4d"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:bc7aee6f634a6f4a95676fcb5d6559a2c2a390330098dba5e5a5f28a2e4ada30"}, + {file = "pydantic_core-2.33.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:235f45e5dbcccf6bd99f9f472858849f73d11120d76ea8707115415f8e5ebebf"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win32.whl", hash = "sha256:6368900c2d3ef09b69cb0b913f9f8263b03786e5b2a387706c5afb66800efd51"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e063337ef9e9820c77acc768546325ebe04ee38b08703244c1309cccc4f1bab"}, + {file = "pydantic_core-2.33.2-cp311-cp311-win_arm64.whl", hash = "sha256:6b99022f1d19bc32a4c2a0d544fc9a76e3be90f0b3f4af413f87d38749300e65"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc"}, + {file = "pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6"}, + {file = "pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2"}, + {file = "pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f"}, + {file = "pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d"}, + {file = "pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e"}, + {file = "pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9"}, + {file = "pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5"}, + {file = "pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:a2b911a5b90e0374d03813674bf0a5fbbb7741570dcd4b4e85a2e48d17def29d"}, + {file = "pydantic_core-2.33.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:6fa6dfc3e4d1f734a34710f391ae822e0a8eb8559a85c6979e14e65ee6ba2954"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c54c939ee22dc8e2d545da79fc5381f1c020d6d3141d3bd747eab59164dc89fb"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:53a57d2ed685940a504248187d5685e49eb5eef0f696853647bf37c418c538f7"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09fb9dd6571aacd023fe6aaca316bd01cf60ab27240d7eb39ebd66a3a15293b4"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0e6116757f7959a712db11f3e9c0a99ade00a5bbedae83cb801985aa154f071b"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d55ab81c57b8ff8548c3e4947f119551253f4e3787a7bbc0b6b3ca47498a9d3"}, + {file = "pydantic_core-2.33.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c20c462aa4434b33a2661701b861604913f912254e441ab8d78d30485736115a"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:44857c3227d3fb5e753d5fe4a3420d6376fa594b07b621e220cd93703fe21782"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:eb9b459ca4df0e5c87deb59d37377461a538852765293f9e6ee834f0435a93b9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:9fcd347d2cc5c23b06de6d3b7b8275be558a0c90549495c699e379a80bf8379e"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win32.whl", hash = "sha256:83aa99b1285bc8f038941ddf598501a86f1536789740991d7d8756e34f1e74d9"}, + {file = "pydantic_core-2.33.2-cp39-cp39-win_amd64.whl", hash = "sha256:f481959862f57f29601ccced557cc2e817bce7533ab8e01a797a48b49c9692b3"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5c4aa4e82353f65e548c476b37e64189783aa5384903bfea4f41580f255fddfa"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d946c8bf0d5c24bf4fe333af284c59a19358aa3ec18cb3dc4370080da1e8ad29"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87b31b6846e361ef83fedb187bb5b4372d0da3f7e28d85415efa92d6125d6e6d"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aa9d91b338f2df0508606f7009fde642391425189bba6d8c653afd80fd6bb64e"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2058a32994f1fde4ca0480ab9d1e75a0e8c87c22b53a3ae66554f9af78f2fe8c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:0e03262ab796d986f978f79c943fc5f620381be7287148b8010b4097f79a39ec"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1a8695a8d00c73e50bff9dfda4d540b7dee29ff9b8053e38380426a85ef10052"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fa754d1850735a0b0e03bcffd9d4b4343eb417e47196e4485d9cca326073a42c"}, + {file = "pydantic_core-2.33.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:a11c8d26a50bfab49002947d3d237abe4d9e4b5bdc8846a63537b6488e197808"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:dd14041875d09cc0f9308e37a6f8b65f5585cf2598a53aa0123df8b129d481f8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d87c561733f66531dced0da6e864f44ebf89a8fba55f31407b00c2f7f9449593"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f82865531efd18d6e07a04a17331af02cb7a651583c418df8266f17a63c6612"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bfb5112df54209d820d7bf9317c7a6c9025ea52e49f46b6a2060104bba37de7"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:64632ff9d614e5eecfb495796ad51b0ed98c453e447a76bcbeeb69615079fc7e"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:f889f7a40498cc077332c7ab6b4608d296d852182211787d4f3ee377aaae66e8"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:de4b83bb311557e439b9e186f733f6c645b9417c84e2eb8203f3f820a4b988bf"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:82f68293f055f51b51ea42fafc74b6aad03e70e191799430b90c13d643059ebb"}, + {file = "pydantic_core-2.33.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:329467cecfb529c925cf2bbd4d60d2c509bc2fb52a20c1045bf09bb70971a9c1"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:87acbfcf8e90ca885206e98359d7dca4bcbb35abdc0ff66672a293e1d7a19101"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f92c15cd1e97d4b12acd1cc9004fa092578acfa57b67ad5e43a197175d01a64"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3f26877a748dc4251cfcfda9dfb5f13fcb034f5308388066bcfe9031b63ae7d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac89aea9af8cd672fa7b510e7b8c33b0bba9a43186680550ccf23020f32d535"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:970919794d126ba8645f3837ab6046fb4e72bbc057b3709144066204c19a455d"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3eb3fe62804e8f859c49ed20a8451342de53ed764150cb14ca71357c765dc2a6"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3abcd9392a36025e3bd55f9bd38d908bd17962cc49bc6da8e7e96285336e2bca"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:3a1c81334778f9e3af2f8aeb7a960736e5cab1dfebfb26aabca09afd2906c039"}, + {file = "pydantic_core-2.33.2-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:2807668ba86cb38c6817ad9bc66215ab8584d1d304030ce4f0887336f28a5e27"}, + {file = "pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc"}, ] [package.dependencies] @@ -2798,41 +4665,80 @@ typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" [[package]] name = "pydantic-settings" -version = "2.7.0" +version = "2.10.1" description = "Settings management using Pydantic" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pydantic_settings-2.7.0-py3-none-any.whl", hash = "sha256:e00c05d5fa6cbbb227c84bd7487c5c1065084119b750df7c8c1a554aed236eb5"}, - {file = "pydantic_settings-2.7.0.tar.gz", hash = "sha256:ac4bfd4a36831a48dbf8b2d9325425b549a0a6f18cea118436d728eb4f1c4d66"}, + {file = "pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796"}, + {file = "pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee"}, ] [package.dependencies] pydantic = ">=2.7.0" python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" [package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] toml = ["tomli (>=2.0.1)"] yaml = ["pyyaml (>=6.0.1)"] +[[package]] +name = "pyee" +version = "13.0.0" +description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498"}, + {file = "pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37"}, +] + +[package.dependencies] +typing-extensions = "*" + +[package.extras] +dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"] + [[package]] name = "pyflakes" -version = "3.2.0" +version = "3.4.0" description = "passive checker of Python programs" optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pyflakes-3.4.0-py2.py3-none-any.whl", hash = "sha256:f742a7dbd0d9cb9ea41e9a24a918996e8170c799fa528688d40dd582c8265f4f"}, + {file = "pyflakes-3.4.0.tar.gz", hash = "sha256:b24f96fafb7d2ab0ec5075b7350b3d2d2218eab42003821c06344973d3ea2f58"}, +] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ - {file = "pyflakes-3.2.0-py2.py3-none-any.whl", hash = "sha256:84b5be138a2dfbb40689ca07e2152deb896a65c3a3e24c251c5c62489568074a"}, - {file = "pyflakes-3.2.0.tar.gz", hash = "sha256:1c61603ff154621fb2a9172037d84dca3500def8c8b630657d1701f026f8af3f"}, + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, ] +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + [[package]] name = "pyjwt" version = "2.10.1" description = "JSON Web Token implementation in Python" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ {file = "PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb"}, {file = "pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953"}, @@ -2846,24 +4752,38 @@ tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] [[package]] name = "pyparsing" -version = "3.2.0" +version = "3.2.3" description = "pyparsing module - Classes and methods to define and execute parsing grammars" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pyparsing-3.2.0-py3-none-any.whl", hash = "sha256:93d9577b88da0bbea8cc8334ee8b918ed014968fd2ec383e868fb8afb1ccef84"}, - {file = "pyparsing-3.2.0.tar.gz", hash = "sha256:cbf74e27246d595d9a74b186b810f6fbb86726dbf3b9532efb343f6d7294fe9c"}, + {file = "pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf"}, + {file = "pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be"}, ] [package.extras] diagrams = ["jinja2", "railroad-diagrams"] +[[package]] +name = "pyproject-hooks" +version = "1.2.0" +description = "Wrappers to call pyproject.toml-based build backend hooks." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pyproject_hooks-1.2.0-py3-none-any.whl", hash = "sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913"}, + {file = "pyproject_hooks-1.2.0.tar.gz", hash = "sha256:1e859bd5c40fae9448642dd871adf459e5e2084186e8d2c2a79a824c970da1f8"}, +] + [[package]] name = "pyrfc3339" version = "2.0.1" description = "Generate and parse RFC 3339 timestamps" optional = false python-versions = "*" +groups = ["main"] files = [ {file = "pyRFC3339-2.0.1-py3-none-any.whl", hash = "sha256:30b70a366acac3df7386b558c21af871522560ed7f3f73cf344b8c2cbb8b0c9d"}, {file = "pyrfc3339-2.0.1.tar.gz", hash = "sha256:e47843379ea35c1296c3b6c67a948a1a490ae0584edfcbdea0eaffb5dd29960b"}, @@ -2871,13 +4791,14 @@ files = [ [[package]] name = "pyright" -version = "1.1.390" +version = "1.1.403" description = "Command line wrapper for pyright" optional = false python-versions = ">=3.7" +groups = ["dev"] files = [ - {file = "pyright-1.1.390-py3-none-any.whl", hash = "sha256:ecebfba5b6b50af7c1a44c2ba144ba2ab542c227eb49bc1f16984ff714e0e110"}, - {file = "pyright-1.1.390.tar.gz", hash = "sha256:aad7f160c49e0fbf8209507a15e17b781f63a86a1facb69ca877c71ef2e9538d"}, + {file = "pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3"}, + {file = "pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104"}, ] [package.dependencies] @@ -2889,54 +4810,44 @@ all = ["nodejs-wheel-binaries", "twine (>=3.4.1)"] dev = ["twine (>=3.4.1)"] nodejs = ["nodejs-wheel-binaries"] -[[package]] -name = "pyro5" -version = "5.15" -description = "Remote object communication library, fifth major version" -optional = false -python-versions = ">=3.7" -files = [ - {file = "Pyro5-5.15-py3-none-any.whl", hash = "sha256:4d85428ed75985e63f159d2486ad5680743ea76f766340fd30b65dd20f83d471"}, - {file = "Pyro5-5.15.tar.gz", hash = "sha256:82c3dfc9860b49f897b28ff24fe6716c841672c600af8fe40d0e3a7fac9a3f5e"}, -] - -[package.dependencies] -serpent = ">=1.41" - [[package]] name = "pytest" -version = "8.3.4" +version = "8.4.1" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "pytest-8.3.4-py3-none-any.whl", hash = "sha256:50e16d954148559c9a74109af1eaf0c945ba2d8f30f0a3d3335edde19788b6f6"}, - {file = "pytest-8.3.4.tar.gz", hash = "sha256:965370d062bce11e73868e0335abac31b4d3de0e82f4007408d242b4f8610761"}, + {file = "pytest-8.4.1-py3-none-any.whl", hash = "sha256:539c70ba6fcead8e78eebbf1115e8b589e7565830d7d006a8723f19ac8a0afb7"}, + {file = "pytest-8.4.1.tar.gz", hash = "sha256:7c67fd69174877359ed9371ec3af8a3d2b04741818c51e5e99cc1742251fa93c"}, ] [package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" pluggy = ">=1.5,<2" +pygments = ">=2.7.2" tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] -dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] [[package]] name = "pytest-asyncio" -version = "0.25.0" +version = "1.1.0" description = "Pytest support for asyncio" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "pytest_asyncio-0.25.0-py3-none-any.whl", hash = "sha256:db5432d18eac6b7e28b46dcd9b69921b55c3b1086e85febfe04e70b18d9e81b3"}, - {file = "pytest_asyncio-0.25.0.tar.gz", hash = "sha256:8c0610303c9e0442a5db8604505fc0f545456ba1528824842b37b4a626cbf609"}, + {file = "pytest_asyncio-1.1.0-py3-none-any.whl", hash = "sha256:5fe2d69607b0bd75c656d1211f969cadba035030156745ee09e7d71740e58ecf"}, + {file = "pytest_asyncio-1.1.0.tar.gz", hash = "sha256:796aa822981e01b68c12e4827b8697108f7205020f24b5793b3c41555dab68ea"}, ] [package.dependencies] +backports-asyncio-runner = {version = ">=1.1,<2", markers = "python_version < \"3.11\""} pytest = ">=8.2,<9" [package.extras] @@ -2945,13 +4856,14 @@ testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] [[package]] name = "pytest-mock" -version = "3.14.0" +version = "3.14.1" description = "Thin-wrapper around the mock package for easier use with pytest" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ - {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, - {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, + {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, + {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, ] [package.dependencies] @@ -2960,12 +4872,28 @@ pytest = ">=6.2.5" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "pytest-snapshot" +version = "0.9.0" +description = "A plugin for snapshot testing with pytest." +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "pytest-snapshot-0.9.0.tar.gz", hash = "sha256:c7013c3abc3e860f9feff899f8b4debe3708650d8d8242a61bf2625ff64db7f3"}, + {file = "pytest_snapshot-0.9.0-py3-none-any.whl", hash = "sha256:4b9fe1c21c868fe53a545e4e3184d36bc1c88946e3f5c1d9dd676962a9b3d4ab"}, +] + +[package.dependencies] +pytest = ">=3.0.0" + [[package]] name = "pytest-watcher" version = "0.4.3" description = "Automatically rerun your tests on file modifications" optional = false python-versions = "<4.0.0,>=3.7.0" +groups = ["dev"] files = [ {file = "pytest_watcher-0.4.3-py3-none-any.whl", hash = "sha256:d59b1e1396f33a65ea4949b713d6884637755d641646960056a90b267c3460f9"}, {file = "pytest_watcher-0.4.3.tar.gz", hash = "sha256:0cb0e4661648c8c0ff2b2d25efa5a8e421784b9e4c60fcecbf9b7c30b2d731b3"}, @@ -2981,6 +4909,7 @@ version = "2.9.0.post0" description = "Extensions to the standard Python datetime module" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] files = [ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, @@ -2991,13 +4920,14 @@ six = ">=1.5" [[package]] name = "python-dotenv" -version = "1.0.1" +version = "1.1.1" description = "Read key-value pairs from a .env file and set them as environment variables" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, - {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, ] [package.extras] @@ -3009,6 +4939,7 @@ version = "0.0.20" description = "A streaming multipart parser for Python" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"}, {file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"}, @@ -3016,13 +4947,58 @@ files = [ [[package]] name = "pytz" -version = "2024.2" +version = "2025.2" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" +groups = ["main"] files = [ - {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, - {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, + {file = "pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00"}, + {file = "pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3"}, +] + +[[package]] +name = "pywin32" +version = "311" +description = "Python for Window Extensions" +optional = false +python-versions = "*" +groups = ["main"] +markers = "platform_system == \"Windows\"" +files = [ + {file = "pywin32-311-cp310-cp310-win32.whl", hash = "sha256:d03ff496d2a0cd4a5893504789d4a15399133fe82517455e78bad62efbb7f0a3"}, + {file = "pywin32-311-cp310-cp310-win_amd64.whl", hash = "sha256:797c2772017851984b97180b0bebe4b620bb86328e8a884bb626156295a63b3b"}, + {file = "pywin32-311-cp310-cp310-win_arm64.whl", hash = "sha256:0502d1facf1fed4839a9a51ccbcc63d952cf318f78ffc00a7e78528ac27d7a2b"}, + {file = "pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151"}, + {file = "pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503"}, + {file = "pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2"}, + {file = "pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31"}, + {file = "pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067"}, + {file = "pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852"}, + {file = "pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d"}, + {file = "pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d"}, + {file = "pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a"}, + {file = "pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee"}, + {file = "pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87"}, + {file = "pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42"}, + {file = "pywin32-311-cp38-cp38-win32.whl", hash = "sha256:6c6f2969607b5023b0d9ce2541f8d2cbb01c4f46bc87456017cf63b73f1e2d8c"}, + {file = "pywin32-311-cp38-cp38-win_amd64.whl", hash = "sha256:c8015b09fb9a5e188f83b7b04de91ddca4658cee2ae6f3bc483f0b21a77ef6cd"}, + {file = "pywin32-311-cp39-cp39-win32.whl", hash = "sha256:aba8f82d551a942cb20d4a83413ccbac30790b50efb89a75e4f586ac0bb8056b"}, + {file = "pywin32-311-cp39-cp39-win_amd64.whl", hash = "sha256:e0c4cfb0621281fe40387df582097fd796e80430597cb9944f0ae70447bacd91"}, + {file = "pywin32-311-cp39-cp39-win_arm64.whl", hash = "sha256:62ea666235135fee79bb154e695f3ff67370afefd71bd7fea7512fc70ef31e3d"}, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +description = "A (partial) reimplementation of pywin32 using ctypes/cffi" +optional = false +python-versions = ">=3.6" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755"}, + {file = "pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8"}, ] [[package]] @@ -3031,6 +5007,7 @@ version = "6.0.2" description = "YAML parser and emitter for Python" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, @@ -3087,65 +5064,310 @@ files = [ {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, ] +[[package]] +name = "qdrant-client" +version = "1.14.3" +description = "Client library for the Qdrant vector search engine" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "qdrant_client-1.14.3-py3-none-any.whl", hash = "sha256:66faaeae00f9b5326946851fe4ca4ddb1ad226490712e2f05142266f68dfc04d"}, + {file = "qdrant_client-1.14.3.tar.gz", hash = "sha256:bb899e3e065b79c04f5e47053d59176150c0a5dabc09d7f476c8ce8e52f4d281"}, +] + +[package.dependencies] +grpcio = ">=1.41.0" +httpx = {version = ">=0.20.0", extras = ["http2"]} +numpy = [ + {version = ">=1.21", markers = "python_version >= \"3.10\" and python_version < \"3.12\""}, + {version = ">=2.1.0", markers = "python_version >= \"3.13\""}, + {version = ">=1.26", markers = "python_version == \"3.12\""}, +] +portalocker = ">=2.7.0,<3.0.0" +protobuf = ">=3.20.0" +pydantic = ">=1.10.8,<2.0.dev0 || >2.2.0" +urllib3 = ">=1.26.14,<3" + +[package.extras] +fastembed = ["fastembed (>=0.7,<0.8)"] +fastembed-gpu = ["fastembed-gpu (>=0.7,<0.8)"] + +[[package]] +name = "rapidfuzz" +version = "3.13.0" +description = "rapid fuzzy string matching" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "rapidfuzz-3.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:aafc42a1dc5e1beeba52cd83baa41372228d6d8266f6d803c16dbabbcc156255"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:85c9a131a44a95f9cac2eb6e65531db014e09d89c4f18c7b1fa54979cb9ff1f3"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d7cec4242d30dd521ef91c0df872e14449d1dffc2a6990ede33943b0dae56c3"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e297c09972698c95649e89121e3550cee761ca3640cd005e24aaa2619175464e"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef0f5f03f61b0e5a57b1df7beafd83df993fd5811a09871bad6038d08e526d0d"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d8cf5f7cd6e4d5eb272baf6a54e182b2c237548d048e2882258336533f3f02b7"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9256218ac8f1a957806ec2fb9a6ddfc6c32ea937c0429e88cf16362a20ed8602"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e1bdd2e6d0c5f9706ef7595773a81ca2b40f3b33fd7f9840b726fb00c6c4eb2e"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5280be8fd7e2bee5822e254fe0a5763aa0ad57054b85a32a3d9970e9b09bbcbf"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fd742c03885db1fce798a1cd87a20f47f144ccf26d75d52feb6f2bae3d57af05"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:5435fcac94c9ecf0504bf88a8a60c55482c32e18e108d6079a0089c47f3f8cf6"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:93a755266856599be4ab6346273f192acde3102d7aa0735e2f48b456397a041f"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-win32.whl", hash = "sha256:3abe6a4e8eb4cfc4cda04dd650a2dc6d2934cbdeda5def7e6fd1c20f6e7d2a0b"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:e8ddb58961401da7d6f55f185512c0d6bd24f529a637078d41dd8ffa5a49c107"}, + {file = "rapidfuzz-3.13.0-cp310-cp310-win_arm64.whl", hash = "sha256:c523620d14ebd03a8d473c89e05fa1ae152821920c3ff78b839218ff69e19ca3"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d395a5cad0c09c7f096433e5fd4224d83b53298d53499945a9b0e5a971a84f3a"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b7b3eda607a019169f7187328a8d1648fb9a90265087f6903d7ee3a8eee01805"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:98e0bfa602e1942d542de077baf15d658bd9d5dcfe9b762aff791724c1c38b70"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bef86df6d59667d9655905b02770a0c776d2853971c0773767d5ef8077acd624"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fedd316c165beed6307bf754dee54d3faca2c47e1f3bcbd67595001dfa11e969"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5158da7f2ec02a930be13bac53bb5903527c073c90ee37804090614cab83c29e"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b6f913ee4618ddb6d6f3e387b76e8ec2fc5efee313a128809fbd44e65c2bbb2"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d25fdbce6459ccbbbf23b4b044f56fbd1158b97ac50994eaae2a1c0baae78301"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:25343ccc589a4579fbde832e6a1e27258bfdd7f2eb0f28cb836d6694ab8591fc"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9ad1f37894e3ffb76bbab76256e8a8b789657183870be11aa64e306bb5228fd"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5dc71ef23845bb6b62d194c39a97bb30ff171389c9812d83030c1199f319098c"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7f4c65facdb94f44be759bbd9b6dda1fa54d0d6169cdf1a209a5ab97d311a75"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-win32.whl", hash = "sha256:b5104b62711565e0ff6deab2a8f5dbf1fbe333c5155abe26d2cfd6f1849b6c87"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:9093cdeb926deb32a4887ebe6910f57fbcdbc9fbfa52252c10b56ef2efb0289f"}, + {file = "rapidfuzz-3.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:f70f646751b6aa9d05be1fb40372f006cc89d6aad54e9d79ae97bd1f5fce5203"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a1a6a906ba62f2556372282b1ef37b26bca67e3d2ea957277cfcefc6275cca7"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2fd0975e015b05c79a97f38883a11236f5a24cca83aa992bd2558ceaa5652b26"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d4e13593d298c50c4f94ce453f757b4b398af3fa0fd2fde693c3e51195b7f69"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed6f416bda1c9133000009d84d9409823eb2358df0950231cc936e4bf784eb97"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1dc82b6ed01acb536b94a43996a94471a218f4d89f3fdd9185ab496de4b2a981"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9d824de871daa6e443b39ff495a884931970d567eb0dfa213d234337343835f"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d18228a2390375cf45726ce1af9d36ff3dc1f11dce9775eae1f1b13ac6ec50f"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5fe634c9482ec5d4a6692afb8c45d370ae86755e5f57aa6c50bfe4ca2bdd87"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:694eb531889f71022b2be86f625a4209c4049e74be9ca836919b9e395d5e33b3"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11b47b40650e06147dee5e51a9c9ad73bb7b86968b6f7d30e503b9f8dd1292db"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:98b8107ff14f5af0243f27d236bcc6e1ef8e7e3b3c25df114e91e3a99572da73"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b836f486dba0aceb2551e838ff3f514a38ee72b015364f739e526d720fdb823a"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-win32.whl", hash = "sha256:4671ee300d1818d7bdfd8fa0608580d7778ba701817216f0c17fb29e6b972514"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:6e2065f68fb1d0bf65adc289c1bdc45ba7e464e406b319d67bb54441a1b9da9e"}, + {file = "rapidfuzz-3.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:65cc97c2fc2c2fe23586599686f3b1ceeedeca8e598cfcc1b7e56dc8ca7e2aa7"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:09e908064d3684c541d312bd4c7b05acb99a2c764f6231bd507d4b4b65226c23"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:57c390336cb50d5d3bfb0cfe1467478a15733703af61f6dffb14b1cd312a6fae"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0da54aa8547b3c2c188db3d1c7eb4d1bb6dd80baa8cdaeaec3d1da3346ec9caa"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:df8e8c21e67afb9d7fbe18f42c6111fe155e801ab103c81109a61312927cc611"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:461fd13250a2adf8e90ca9a0e1e166515cbcaa5e9c3b1f37545cbbeff9e77f6b"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c2b3dd5d206a12deca16870acc0d6e5036abeb70e3cad6549c294eff15591527"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1343d745fbf4688e412d8f398c6e6d6f269db99a54456873f232ba2e7aeb4939"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b1b065f370d54551dcc785c6f9eeb5bd517ae14c983d2784c064b3aa525896df"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:11b125d8edd67e767b2295eac6eb9afe0b1cdc82ea3d4b9257da4b8e06077798"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c33f9c841630b2bb7e69a3fb5c84a854075bb812c47620978bddc591f764da3d"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ae4574cb66cf1e85d32bb7e9ec45af5409c5b3970b7ceb8dea90168024127566"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e05752418b24bbd411841b256344c26f57da1148c5509e34ea39c7eb5099ab72"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-win32.whl", hash = "sha256:0e1d08cb884805a543f2de1f6744069495ef527e279e05370dd7c83416af83f8"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9a7c6232be5f809cd39da30ee5d24e6cadd919831e6020ec6c2391f4c3bc9264"}, + {file = "rapidfuzz-3.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:3f32f15bacd1838c929b35c84b43618481e1b3d7a61b5ed2db0291b70ae88b53"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:cc64da907114d7a18b5e589057e3acaf2fec723d31c49e13fedf043592a3f6a7"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4d9d7f84c8e992a8dbe5a3fdbea73d733da39bf464e62c912ac3ceba9c0cff93"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a79a2f07786a2070669b4b8e45bd96a01c788e7a3c218f531f3947878e0f956"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9f338e71c45b69a482de8b11bf4a029993230760120c8c6e7c9b71760b6825a1"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb40ca8ddfcd4edd07b0713a860be32bdf632687f656963bcbce84cea04b8d8"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48719f7dcf62dfb181063b60ee2d0a39d327fa8ad81b05e3e510680c44e1c078"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9327a4577f65fc3fb712e79f78233815b8a1c94433d0c2c9f6bc5953018b3565"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:200030dfc0a1d5d6ac18e993c5097c870c97c41574e67f227300a1fb74457b1d"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:cc269e74cad6043cb8a46d0ce580031ab642b5930562c2bb79aa7fbf9c858d26"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:e62779c6371bd2b21dbd1fdce89eaec2d93fd98179d36f61130b489f62294a92"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:f4797f821dc5d7c2b6fc818b89f8a3f37bcc900dd9e4369e6ebf1e525efce5db"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:d21f188f6fe4fbf422e647ae9d5a68671d00218e187f91859c963d0738ccd88c"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-win32.whl", hash = "sha256:45dd4628dd9c21acc5c97627dad0bb791764feea81436fb6e0a06eef4c6dceaa"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:624a108122039af89ddda1a2b7ab2a11abe60c1521956f142f5d11bcd42ef138"}, + {file = "rapidfuzz-3.13.0-cp39-cp39-win_arm64.whl", hash = "sha256:435071fd07a085ecbf4d28702a66fd2e676a03369ee497cc38bcb69a46bc77e2"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fe5790a36d33a5d0a6a1f802aa42ecae282bf29ac6f7506d8e12510847b82a45"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:cdb33ee9f8a8e4742c6b268fa6bd739024f34651a06b26913381b1413ebe7590"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8c99b76b93f7b495eee7dcb0d6a38fb3ce91e72e99d9f78faa5664a881cb2b7d"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6af42f2ede8b596a6aaf6d49fdee3066ca578f4856b85ab5c1e2145de367a12d"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c0efa73afbc5b265aca0d8a467ae2a3f40d6854cbe1481cb442a62b7bf23c99"}, + {file = "rapidfuzz-3.13.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:7ac21489de962a4e2fc1e8f0b0da4aa1adc6ab9512fd845563fecb4b4c52093a"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1ba007f4d35a45ee68656b2eb83b8715e11d0f90e5b9f02d615a8a321ff00c27"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d7a217310429b43be95b3b8ad7f8fc41aba341109dc91e978cd7c703f928c58f"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:558bf526bcd777de32b7885790a95a9548ffdcce68f704a81207be4a286c1095"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:202a87760f5145140d56153b193a797ae9338f7939eb16652dd7ff96f8faf64c"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfcccc08f671646ccb1e413c773bb92e7bba789e3a1796fd49d23c12539fe2e4"}, + {file = "rapidfuzz-3.13.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:1f219f1e3c3194d7a7de222f54450ce12bc907862ff9a8962d83061c1f923c86"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ccbd0e7ea1a216315f63ffdc7cd09c55f57851afc8fe59a74184cb7316c0598b"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:a50856f49a4016ef56edd10caabdaf3608993f9faf1e05c3c7f4beeac46bd12a"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fd05336db4d0b8348d7eaaf6fa3c517b11a56abaa5e89470ce1714e73e4aca7"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:573ad267eb9b3f6e9b04febce5de55d8538a87c56c64bf8fd2599a48dc9d8b77"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30fd1451f87ccb6c2f9d18f6caa483116bbb57b5a55d04d3ddbd7b86f5b14998"}, + {file = "rapidfuzz-3.13.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:a6dd36d4916cf57ddb05286ed40b09d034ca5d4bca85c17be0cb6a21290597d9"}, + {file = "rapidfuzz-3.13.0.tar.gz", hash = "sha256:d2eaf3839e52cbcc0accbe9817a67b4b0fcf70aaeb229cfddc1c28061f9ce5d8"}, +] + +[package.extras] +all = ["numpy"] + [[package]] name = "realtime" -version = "2.0.6" +version = "2.6.0" description = "" optional = false -python-versions = "<4.0,>=3.9" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "realtime-2.0.6-py3-none-any.whl", hash = "sha256:9aab6009c11883197386a0a9dc8c2b6939e62dddda734cfb77594727ac9ae0ce"}, - {file = "realtime-2.0.6.tar.gz", hash = "sha256:ced37686a77a546571029ecc74cfb31fff1404a5159d1198fa882af545843a6f"}, + {file = "realtime-2.6.0-py3-none-any.whl", hash = "sha256:a0512d71044c2621455bc87d1c171739967edc161381994de54e0989ca6c348e"}, + {file = "realtime-2.6.0.tar.gz", hash = "sha256:f68743cff85d3113659fa19835a868674e720465649bf833e1cd47d7da0f7bbd"}, ] [package.dependencies] -aiohttp = ">=3.10.10,<4.0.0" -python-dateutil = ">=2.8.1,<3.0.0" -typing-extensions = ">=4.12.2,<5.0.0" -websockets = ">=11,<14" +pydantic = ">=2.11.7,<3.0.0" +typing-extensions = ">=4.14.0" +websockets = ">=11,<16" [[package]] name = "redis" -version = "5.2.1" +version = "6.2.0" description = "Python client for Redis database and key-value store" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4"}, - {file = "redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f"}, + {file = "redis-6.2.0-py3-none-any.whl", hash = "sha256:c8ddf316ee0aab65f04a11229e94a64b2618451dab7a67cb2f77eb799d872d5e"}, + {file = "redis-6.2.0.tar.gz", hash = "sha256:e821f129b75dde6cb99dd35e5c76e8c49512a5a0d8dfdc560b2fbd44b85ca977"}, ] [package.dependencies] async-timeout = {version = ">=4.0.3", markers = "python_full_version < \"3.11.3\""} [package.extras] -hiredis = ["hiredis (>=3.0.0)"] -ocsp = ["cryptography (>=36.0.1)", "pyopenssl (==23.2.1)", "requests (>=2.31.0)"] +hiredis = ["hiredis (>=3.2.0)"] +jwt = ["pyjwt (>=2.9.0)"] +ocsp = ["cryptography (>=36.0.1)", "pyopenssl (>=20.0.1)", "requests (>=2.31.0)"] [[package]] name = "referencing" -version = "0.35.1" +version = "0.36.2" description = "JSON Referencing + Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "referencing-0.35.1-py3-none-any.whl", hash = "sha256:eda6d3234d62814d1c64e305c1331c9a3a6132da475ab6382eaa997b21ee75de"}, - {file = "referencing-0.35.1.tar.gz", hash = "sha256:25b42124a6c8b632a425174f24087783efb348a6f1e0008e63cd4466fedf703c"}, + {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, + {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, ] [package.dependencies] attrs = ">=22.2.0" rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + +[[package]] +name = "regex" +version = "2024.11.6" +description = "Alternative regular expression module, to replace re." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, + {file = "regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c"}, + {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008"}, + {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62"}, + {file = "regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e"}, + {file = "regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7"}, + {file = "regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0"}, + {file = "regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d"}, + {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45"}, + {file = "regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9"}, + {file = "regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9"}, + {file = "regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e"}, + {file = "regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51"}, + {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad"}, + {file = "regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54"}, + {file = "regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4"}, + {file = "regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c"}, + {file = "regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4"}, + {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d"}, + {file = "regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff"}, + {file = "regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3"}, + {file = "regex-2024.11.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f"}, + {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc"}, + {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f"}, + {file = "regex-2024.11.6-cp38-cp38-win32.whl", hash = "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4"}, + {file = "regex-2024.11.6-cp38-cp38-win_amd64.whl", hash = "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e"}, + {file = "regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48"}, + {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f"}, + {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b"}, + {file = "regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57"}, + {file = "regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983"}, + {file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"}, +] [[package]] name = "replicate" -version = "1.0.4" +version = "1.0.7" description = "Python client for Replicate" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "replicate-1.0.4-py3-none-any.whl", hash = "sha256:f568f6271ff715067901b6094c23c37373bbcfd7de0ff9b85e9c9ead567e09e7"}, - {file = "replicate-1.0.4.tar.gz", hash = "sha256:f718601863ef1f419aa7dcdab1ea8770ba5489b571b86edf840cd506d68758ef"}, + {file = "replicate-1.0.7-py3-none-any.whl", hash = "sha256:667c50a9eb83be17de6278ff89483102b3b50f49a2c7fbcaa2e2b14df13816f9"}, + {file = "replicate-1.0.7.tar.gz", hash = "sha256:d88cb2c37ba39fb370c87fc3291601c67aae64bb918a20a85b5ce399c23ee84c"}, ] [package.dependencies] @@ -3156,18 +5378,19 @@ typing_extensions = ">=4.5.0" [[package]] name = "requests" -version = "2.32.3" +version = "2.32.4" description = "Python HTTP for Humans." optional = false python-versions = ">=3.8" +groups = ["main", "dev"] files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, + {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"}, + {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"}, ] [package.dependencies] certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" +charset_normalizer = ">=2,<4" idna = ">=2.5,<4" urllib3 = ">=1.21.1,<3" @@ -3181,6 +5404,7 @@ version = "2.0.0" description = "OAuthlib authentication support for Requests." optional = false python-versions = ">=3.4" +groups = ["main"] files = [ {file = "requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9"}, {file = "requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36"}, @@ -3193,127 +5417,204 @@ requests = ">=2.0.0" [package.extras] rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +[[package]] +name = "requests-toolbelt" +version = "1.0.0" +description = "A utility belt for advanced users of python-requests" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6"}, + {file = "requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06"}, +] + +[package.dependencies] +requests = ">=2.0.1,<3.0.0" + +[[package]] +name = "rich" +version = "14.1.0" +description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal" +optional = false +python-versions = ">=3.8.0" +groups = ["main"] +files = [ + {file = "rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f"}, + {file = "rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8"}, +] + +[package.dependencies] +markdown-it-py = ">=2.2.0" +pygments = ">=2.13.0,<3.0.0" + +[package.extras] +jupyter = ["ipywidgets (>=7.5.1,<9)"] + [[package]] name = "rpds-py" -version = "0.22.3" +version = "0.26.0" description = "Python bindings to Rust's persistent data structures (rpds)" optional = false python-versions = ">=3.9" -files = [ - {file = "rpds_py-0.22.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:6c7b99ca52c2c1752b544e310101b98a659b720b21db00e65edca34483259967"}, - {file = "rpds_py-0.22.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be2eb3f2495ba669d2a985f9b426c1797b7d48d6963899276d22f23e33d47e37"}, - {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70eb60b3ae9245ddea20f8a4190bd79c705a22f8028aaf8bbdebe4716c3fab24"}, - {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4041711832360a9b75cfb11b25a6a97c8fb49c07b8bd43d0d02b45d0b499a4ff"}, - {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64607d4cbf1b7e3c3c8a14948b99345eda0e161b852e122c6bb71aab6d1d798c"}, - {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e69b0a0e2537f26d73b4e43ad7bc8c8efb39621639b4434b76a3de50c6966e"}, - {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc27863442d388870c1809a87507727b799c8460573cfbb6dc0eeaef5a11b5ec"}, - {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e79dd39f1e8c3504be0607e5fc6e86bb60fe3584bec8b782578c3b0fde8d932c"}, - {file = "rpds_py-0.22.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e0fa2d4ec53dc51cf7d3bb22e0aa0143966119f42a0c3e4998293a3dd2856b09"}, - {file = "rpds_py-0.22.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fda7cb070f442bf80b642cd56483b5548e43d366fe3f39b98e67cce780cded00"}, - {file = "rpds_py-0.22.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cff63a0272fcd259dcc3be1657b07c929c466b067ceb1c20060e8d10af56f5bf"}, - {file = "rpds_py-0.22.3-cp310-cp310-win32.whl", hash = "sha256:9bd7228827ec7bb817089e2eb301d907c0d9827a9e558f22f762bb690b131652"}, - {file = "rpds_py-0.22.3-cp310-cp310-win_amd64.whl", hash = "sha256:9beeb01d8c190d7581a4d59522cd3d4b6887040dcfc744af99aa59fef3e041a8"}, - {file = "rpds_py-0.22.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d20cfb4e099748ea39e6f7b16c91ab057989712d31761d3300d43134e26e165f"}, - {file = "rpds_py-0.22.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:68049202f67380ff9aa52f12e92b1c30115f32e6895cd7198fa2a7961621fc5a"}, - {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb4f868f712b2dd4bcc538b0a0c1f63a2b1d584c925e69a224d759e7070a12d5"}, - {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc51abd01f08117283c5ebf64844a35144a0843ff7b2983e0648e4d3d9f10dbb"}, - {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f3cec041684de9a4684b1572fe28c7267410e02450f4561700ca5a3bc6695a2"}, - {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ef9d9da710be50ff6809fed8f1963fecdfecc8b86656cadfca3bc24289414b0"}, - {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f4a79c19232a5774aee369a0c296712ad0e77f24e62cad53160312b1c1eaa1"}, - {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a60bce91f81ddaac922a40bbb571a12c1070cb20ebd6d49c48e0b101d87300d"}, - {file = "rpds_py-0.22.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e89391e6d60251560f0a8f4bd32137b077a80d9b7dbe6d5cab1cd80d2746f648"}, - {file = "rpds_py-0.22.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e3fb866d9932a3d7d0c82da76d816996d1667c44891bd861a0f97ba27e84fc74"}, - {file = "rpds_py-0.22.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1352ae4f7c717ae8cba93421a63373e582d19d55d2ee2cbb184344c82d2ae55a"}, - {file = "rpds_py-0.22.3-cp311-cp311-win32.whl", hash = "sha256:b0b4136a252cadfa1adb705bb81524eee47d9f6aab4f2ee4fa1e9d3cd4581f64"}, - {file = "rpds_py-0.22.3-cp311-cp311-win_amd64.whl", hash = "sha256:8bd7c8cfc0b8247c8799080fbff54e0b9619e17cdfeb0478ba7295d43f635d7c"}, - {file = "rpds_py-0.22.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:27e98004595899949bd7a7b34e91fa7c44d7a97c40fcaf1d874168bb652ec67e"}, - {file = "rpds_py-0.22.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1978d0021e943aae58b9b0b196fb4895a25cc53d3956b8e35e0b7682eefb6d56"}, - {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:655ca44a831ecb238d124e0402d98f6212ac527a0ba6c55ca26f616604e60a45"}, - {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feea821ee2a9273771bae61194004ee2fc33f8ec7db08117ef9147d4bbcbca8e"}, - {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22bebe05a9ffc70ebfa127efbc429bc26ec9e9b4ee4d15a740033efda515cf3d"}, - {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3af6e48651c4e0d2d166dc1b033b7042ea3f871504b6805ba5f4fe31581d8d38"}, - {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67ba3c290821343c192f7eae1d8fd5999ca2dc99994114643e2f2d3e6138b15"}, - {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:02fbb9c288ae08bcb34fb41d516d5eeb0455ac35b5512d03181d755d80810059"}, - {file = "rpds_py-0.22.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f56a6b404f74ab372da986d240e2e002769a7d7102cc73eb238a4f72eec5284e"}, - {file = "rpds_py-0.22.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0a0461200769ab3b9ab7e513f6013b7a97fdeee41c29b9db343f3c5a8e2b9e61"}, - {file = "rpds_py-0.22.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8633e471c6207a039eff6aa116e35f69f3156b3989ea3e2d755f7bc41754a4a7"}, - {file = "rpds_py-0.22.3-cp312-cp312-win32.whl", hash = "sha256:593eba61ba0c3baae5bc9be2f5232430453fb4432048de28399ca7376de9c627"}, - {file = "rpds_py-0.22.3-cp312-cp312-win_amd64.whl", hash = "sha256:d115bffdd417c6d806ea9069237a4ae02f513b778e3789a359bc5856e0404cc4"}, - {file = "rpds_py-0.22.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ea7433ce7e4bfc3a85654aeb6747babe3f66eaf9a1d0c1e7a4435bbdf27fea84"}, - {file = "rpds_py-0.22.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6dd9412824c4ce1aca56c47b0991e65bebb7ac3f4edccfd3f156150c96a7bf25"}, - {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20070c65396f7373f5df4005862fa162db5d25d56150bddd0b3e8214e8ef45b4"}, - {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b09865a9abc0ddff4e50b5ef65467cd94176bf1e0004184eb915cbc10fc05c5"}, - {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3453e8d41fe5f17d1f8e9c383a7473cd46a63661628ec58e07777c2fff7196dc"}, - {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5d36399a1b96e1a5fdc91e0522544580dbebeb1f77f27b2b0ab25559e103b8b"}, - {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:009de23c9c9ee54bf11303a966edf4d9087cd43a6003672e6aa7def643d06518"}, - {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1aef18820ef3e4587ebe8b3bc9ba6e55892a6d7b93bac6d29d9f631a3b4befbd"}, - {file = "rpds_py-0.22.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f60bd8423be1d9d833f230fdbccf8f57af322d96bcad6599e5a771b151398eb2"}, - {file = "rpds_py-0.22.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:62d9cfcf4948683a18a9aff0ab7e1474d407b7bab2ca03116109f8464698ab16"}, - {file = "rpds_py-0.22.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9253fc214112405f0afa7db88739294295f0e08466987f1d70e29930262b4c8f"}, - {file = "rpds_py-0.22.3-cp313-cp313-win32.whl", hash = "sha256:fb0ba113b4983beac1a2eb16faffd76cb41e176bf58c4afe3e14b9c681f702de"}, - {file = "rpds_py-0.22.3-cp313-cp313-win_amd64.whl", hash = "sha256:c58e2339def52ef6b71b8f36d13c3688ea23fa093353f3a4fee2556e62086ec9"}, - {file = "rpds_py-0.22.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f82a116a1d03628a8ace4859556fb39fd1424c933341a08ea3ed6de1edb0283b"}, - {file = "rpds_py-0.22.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3dfcbc95bd7992b16f3f7ba05af8a64ca694331bd24f9157b49dadeeb287493b"}, - {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59259dc58e57b10e7e18ce02c311804c10c5a793e6568f8af4dead03264584d1"}, - {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5725dd9cc02068996d4438d397e255dcb1df776b7ceea3b9cb972bdb11260a83"}, - {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99b37292234e61325e7a5bb9689e55e48c3f5f603af88b1642666277a81f1fbd"}, - {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27b1d3b3915a99208fee9ab092b8184c420f2905b7d7feb4aeb5e4a9c509b8a1"}, - {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f612463ac081803f243ff13cccc648578e2279295048f2a8d5eb430af2bae6e3"}, - {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f73d3fef726b3243a811121de45193c0ca75f6407fe66f3f4e183c983573e130"}, - {file = "rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3f21f0495edea7fdbaaa87e633a8689cd285f8f4af5c869f27bc8074638ad69c"}, - {file = "rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1e9663daaf7a63ceccbbb8e3808fe90415b0757e2abddbfc2e06c857bf8c5e2b"}, - {file = "rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a76e42402542b1fae59798fab64432b2d015ab9d0c8c47ba7addddbaf7952333"}, - {file = "rpds_py-0.22.3-cp313-cp313t-win32.whl", hash = "sha256:69803198097467ee7282750acb507fba35ca22cc3b85f16cf45fb01cb9097730"}, - {file = "rpds_py-0.22.3-cp313-cp313t-win_amd64.whl", hash = "sha256:f5cf2a0c2bdadf3791b5c205d55a37a54025c6e18a71c71f82bb536cf9a454bf"}, - {file = "rpds_py-0.22.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:378753b4a4de2a7b34063d6f95ae81bfa7b15f2c1a04a9518e8644e81807ebea"}, - {file = "rpds_py-0.22.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3445e07bf2e8ecfeef6ef67ac83de670358abf2996916039b16a218e3d95e97e"}, - {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b2513ba235829860b13faa931f3b6846548021846ac808455301c23a101689d"}, - {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eaf16ae9ae519a0e237a0f528fd9f0197b9bb70f40263ee57ae53c2b8d48aeb3"}, - {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:583f6a1993ca3369e0f80ba99d796d8e6b1a3a2a442dd4e1a79e652116413091"}, - {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4617e1915a539a0d9a9567795023de41a87106522ff83fbfaf1f6baf8e85437e"}, - {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c150c7a61ed4a4f4955a96626574e9baf1adf772c2fb61ef6a5027e52803543"}, - {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fa4331c200c2521512595253f5bb70858b90f750d39b8cbfd67465f8d1b596d"}, - {file = "rpds_py-0.22.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:214b7a953d73b5e87f0ebece4a32a5bd83c60a3ecc9d4ec8f1dca968a2d91e99"}, - {file = "rpds_py-0.22.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f47ad3d5f3258bd7058d2d506852217865afefe6153a36eb4b6928758041d831"}, - {file = "rpds_py-0.22.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f276b245347e6e36526cbd4a266a417796fc531ddf391e43574cf6466c492520"}, - {file = "rpds_py-0.22.3-cp39-cp39-win32.whl", hash = "sha256:bbb232860e3d03d544bc03ac57855cd82ddf19c7a07651a7c0fdb95e9efea8b9"}, - {file = "rpds_py-0.22.3-cp39-cp39-win_amd64.whl", hash = "sha256:cfbc454a2880389dbb9b5b398e50d439e2e58669160f27b60e5eca11f68ae17c"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d48424e39c2611ee1b84ad0f44fb3b2b53d473e65de061e3f460fc0be5f1939d"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:24e8abb5878e250f2eb0d7859a8e561846f98910326d06c0d51381fed59357bd"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b232061ca880db21fa14defe219840ad9b74b6158adb52ddf0e87bead9e8493"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac0a03221cdb5058ce0167ecc92a8c89e8d0decdc9e99a2ec23380793c4dcb96"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb0c341fa71df5a4595f9501df4ac5abfb5a09580081dffbd1ddd4654e6e9123"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf9db5488121b596dbfc6718c76092fda77b703c1f7533a226a5a9f65248f8ad"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b8db6b5b2d4491ad5b6bdc2bc7c017eec108acbf4e6785f42a9eb0ba234f4c9"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b3d504047aba448d70cf6fa22e06cb09f7cbd761939fdd47604f5e007675c24e"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:e61b02c3f7a1e0b75e20c3978f7135fd13cb6cf551bf4a6d29b999a88830a338"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:e35ba67d65d49080e8e5a1dd40101fccdd9798adb9b050ff670b7d74fa41c566"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:26fd7cac7dd51011a245f29a2cc6489c4608b5a8ce8d75661bb4a1066c52dfbe"}, - {file = "rpds_py-0.22.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:177c7c0fce2855833819c98e43c262007f42ce86651ffbb84f37883308cb0e7d"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:bb47271f60660803ad11f4c61b42242b8c1312a31c98c578f79ef9387bbde21c"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:70fb28128acbfd264eda9bf47015537ba3fe86e40d046eb2963d75024be4d055"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44d61b4b7d0c2c9ac019c314e52d7cbda0ae31078aabd0f22e583af3e0d79723"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0e260eaf54380380ac3808aa4ebe2d8ca28b9087cf411649f96bad6900c728"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b25bc607423935079e05619d7de556c91fb6adeae9d5f80868dde3468657994b"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fb6116dfb8d1925cbdb52595560584db42a7f664617a1f7d7f6e32f138cdf37d"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a63cbdd98acef6570c62b92a1e43266f9e8b21e699c363c0fef13bd530799c11"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b8f60e1b739a74bab7e01fcbe3dddd4657ec685caa04681df9d562ef15b625f"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2e8b55d8517a2fda8d95cb45d62a5a8bbf9dd0ad39c5b25c8833efea07b880ca"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:2de29005e11637e7a2361fa151f780ff8eb2543a0da1413bb951e9f14b699ef3"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:666ecce376999bf619756a24ce15bb14c5bfaf04bf00abc7e663ce17c3f34fe7"}, - {file = "rpds_py-0.22.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:5246b14ca64a8675e0a7161f7af68fe3e910e6b90542b4bfb5439ba752191df6"}, - {file = "rpds_py-0.22.3.tar.gz", hash = "sha256:e32fee8ab45d3c2db6da19a5323bc3362237c8b653c70194414b892fd06a080d"}, +groups = ["main"] +files = [ + {file = "rpds_py-0.26.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:4c70c70f9169692b36307a95f3d8c0a9fcd79f7b4a383aad5eaa0e9718b79b37"}, + {file = "rpds_py-0.26.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:777c62479d12395bfb932944e61e915741e364c843afc3196b694db3d669fcd0"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec671691e72dff75817386aa02d81e708b5a7ec0dec6669ec05213ff6b77e1bd"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6a1cb5d6ce81379401bbb7f6dbe3d56de537fb8235979843f0d53bc2e9815a79"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4f789e32fa1fb6a7bf890e0124e7b42d1e60d28ebff57fe806719abb75f0e9a3"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9c55b0a669976cf258afd718de3d9ad1b7d1fe0a91cd1ab36f38b03d4d4aeaaf"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c70d9ec912802ecfd6cd390dadb34a9578b04f9bcb8e863d0a7598ba5e9e7ccc"}, + {file = "rpds_py-0.26.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3021933c2cb7def39d927b9862292e0f4c75a13d7de70eb0ab06efed4c508c19"}, + {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:8a7898b6ca3b7d6659e55cdac825a2e58c638cbf335cde41f4619e290dd0ad11"}, + {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:12bff2ad9447188377f1b2794772f91fe68bb4bbfa5a39d7941fbebdbf8c500f"}, + {file = "rpds_py-0.26.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:191aa858f7d4902e975d4cf2f2d9243816c91e9605070aeb09c0a800d187e323"}, + {file = "rpds_py-0.26.0-cp310-cp310-win32.whl", hash = "sha256:b37a04d9f52cb76b6b78f35109b513f6519efb481d8ca4c321f6a3b9580b3f45"}, + {file = "rpds_py-0.26.0-cp310-cp310-win_amd64.whl", hash = "sha256:38721d4c9edd3eb6670437d8d5e2070063f305bfa2d5aa4278c51cedcd508a84"}, + {file = "rpds_py-0.26.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:9e8cb77286025bdb21be2941d64ac6ca016130bfdcd228739e8ab137eb4406ed"}, + {file = "rpds_py-0.26.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5e09330b21d98adc8ccb2dbb9fc6cb434e8908d4c119aeaa772cb1caab5440a0"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c9c1b92b774b2e68d11193dc39620d62fd8ab33f0a3c77ecdabe19c179cdbc1"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:824e6d3503ab990d7090768e4dfd9e840837bae057f212ff9f4f05ec6d1975e7"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ad7fd2258228bf288f2331f0a6148ad0186b2e3643055ed0db30990e59817a6"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dc23bbb3e06ec1ea72d515fb572c1fea59695aefbffb106501138762e1e915e"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d80bf832ac7b1920ee29a426cdca335f96a2b5caa839811803e999b41ba9030d"}, + {file = "rpds_py-0.26.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:0919f38f5542c0a87e7b4afcafab6fd2c15386632d249e9a087498571250abe3"}, + {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d422b945683e409000c888e384546dbab9009bb92f7c0b456e217988cf316107"}, + {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:77a7711fa562ba2da1aa757e11024ad6d93bad6ad7ede5afb9af144623e5f76a"}, + {file = "rpds_py-0.26.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238e8c8610cb7c29460e37184f6799547f7e09e6a9bdbdab4e8edb90986a2318"}, + {file = "rpds_py-0.26.0-cp311-cp311-win32.whl", hash = "sha256:893b022bfbdf26d7bedb083efeea624e8550ca6eb98bf7fea30211ce95b9201a"}, + {file = "rpds_py-0.26.0-cp311-cp311-win_amd64.whl", hash = "sha256:87a5531de9f71aceb8af041d72fc4cab4943648d91875ed56d2e629bef6d4c03"}, + {file = "rpds_py-0.26.0-cp311-cp311-win_arm64.whl", hash = "sha256:de2713f48c1ad57f89ac25b3cb7daed2156d8e822cf0eca9b96a6f990718cc41"}, + {file = "rpds_py-0.26.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:894514d47e012e794f1350f076c427d2347ebf82f9b958d554d12819849a369d"}, + {file = "rpds_py-0.26.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc921b96fa95a097add244da36a1d9e4f3039160d1d30f1b35837bf108c21136"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1157659470aa42a75448b6e943c895be8c70531c43cb78b9ba990778955582"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:521ccf56f45bb3a791182dc6b88ae5f8fa079dd705ee42138c76deb1238e554e"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9def736773fd56b305c0eef698be5192c77bfa30d55a0e5885f80126c4831a15"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdad4ea3b4513b475e027be79e5a0ceac8ee1c113a1a11e5edc3c30c29f964d8"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b165b07f416bdccf5c84546a484cc8f15137ca38325403864bfdf2b5b72f6a"}, + {file = "rpds_py-0.26.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d04cab0a54b9dba4d278fe955a1390da3cf71f57feb78ddc7cb67cbe0bd30323"}, + {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:79061ba1a11b6a12743a2b0f72a46aa2758613d454aa6ba4f5a265cc48850158"}, + {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f405c93675d8d4c5ac87364bb38d06c988e11028a64b52a47158a355079661f3"}, + {file = "rpds_py-0.26.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dafd4c44b74aa4bed4b250f1aed165b8ef5de743bcca3b88fc9619b6087093d2"}, + {file = "rpds_py-0.26.0-cp312-cp312-win32.whl", hash = "sha256:3da5852aad63fa0c6f836f3359647870e21ea96cf433eb393ffa45263a170d44"}, + {file = "rpds_py-0.26.0-cp312-cp312-win_amd64.whl", hash = "sha256:cf47cfdabc2194a669dcf7a8dbba62e37a04c5041d2125fae0233b720da6f05c"}, + {file = "rpds_py-0.26.0-cp312-cp312-win_arm64.whl", hash = "sha256:20ab1ae4fa534f73647aad289003f1104092890849e0266271351922ed5574f8"}, + {file = "rpds_py-0.26.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:696764a5be111b036256c0b18cd29783fab22154690fc698062fc1b0084b511d"}, + {file = "rpds_py-0.26.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e6c15d2080a63aaed876e228efe4f814bc7889c63b1e112ad46fdc8b368b9e1"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:390e3170babf42462739a93321e657444f0862c6d722a291accc46f9d21ed04e"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7da84c2c74c0f5bc97d853d9e17bb83e2dcafcff0dc48286916001cc114379a1"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4c5fe114a6dd480a510b6d3661d09d67d1622c4bf20660a474507aaee7eeeee9"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3100b3090269f3a7ea727b06a6080d4eb7439dca4c0e91a07c5d133bb1727ea7"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c03c9b0c64afd0320ae57de4c982801271c0c211aa2d37f3003ff5feb75bb04"}, + {file = "rpds_py-0.26.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5963b72ccd199ade6ee493723d18a3f21ba7d5b957017607f815788cef50eaf1"}, + {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9da4e873860ad5bab3291438525cae80169daecbfafe5657f7f5fb4d6b3f96b9"}, + {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5afaddaa8e8c7f1f7b4c5c725c0070b6eed0228f705b90a1732a48e84350f4e9"}, + {file = "rpds_py-0.26.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4916dc96489616a6f9667e7526af8fa693c0fdb4f3acb0e5d9f4400eb06a47ba"}, + {file = "rpds_py-0.26.0-cp313-cp313-win32.whl", hash = "sha256:2a343f91b17097c546b93f7999976fd6c9d5900617aa848c81d794e062ab302b"}, + {file = "rpds_py-0.26.0-cp313-cp313-win_amd64.whl", hash = "sha256:0a0b60701f2300c81b2ac88a5fb893ccfa408e1c4a555a77f908a2596eb875a5"}, + {file = "rpds_py-0.26.0-cp313-cp313-win_arm64.whl", hash = "sha256:257d011919f133a4746958257f2c75238e3ff54255acd5e3e11f3ff41fd14256"}, + {file = "rpds_py-0.26.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:529c8156d7506fba5740e05da8795688f87119cce330c244519cf706a4a3d618"}, + {file = "rpds_py-0.26.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f53ec51f9d24e9638a40cabb95078ade8c99251945dad8d57bf4aabe86ecee35"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab504c4d654e4a29558eaa5bb8cea5fdc1703ea60a8099ffd9c758472cf913f"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd0641abca296bc1a00183fe44f7fced8807ed49d501f188faa642d0e4975b83"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:69b312fecc1d017b5327afa81d4da1480f51c68810963a7336d92203dbb3d4f1"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c741107203954f6fc34d3066d213d0a0c40f7bb5aafd698fb39888af277c70d8"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc3e55a7db08dc9a6ed5fb7103019d2c1a38a349ac41901f9f66d7f95750942f"}, + {file = "rpds_py-0.26.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9e851920caab2dbcae311fd28f4313c6953993893eb5c1bb367ec69d9a39e7ed"}, + {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dfbf280da5f876d0b00c81f26bedce274e72a678c28845453885a9b3c22ae632"}, + {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1cc81d14ddfa53d7f3906694d35d54d9d3f850ef8e4e99ee68bc0d1e5fed9a9c"}, + {file = "rpds_py-0.26.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dca83c498b4650a91efcf7b88d669b170256bf8017a5db6f3e06c2bf031f57e0"}, + {file = "rpds_py-0.26.0-cp313-cp313t-win32.whl", hash = "sha256:4d11382bcaf12f80b51d790dee295c56a159633a8e81e6323b16e55d81ae37e9"}, + {file = "rpds_py-0.26.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff110acded3c22c033e637dd8896e411c7d3a11289b2edf041f86663dbc791e9"}, + {file = "rpds_py-0.26.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:da619979df60a940cd434084355c514c25cf8eb4cf9a508510682f6c851a4f7a"}, + {file = "rpds_py-0.26.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ea89a2458a1a75f87caabefe789c87539ea4e43b40f18cff526052e35bbb4fdf"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:feac1045b3327a45944e7dcbeb57530339f6b17baff154df51ef8b0da34c8c12"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b818a592bd69bfe437ee8368603d4a2d928c34cffcdf77c2e761a759ffd17d20"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1a8b0dd8648709b62d9372fc00a57466f5fdeefed666afe3fea5a6c9539a0331"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d3498ad0df07d81112aa6ec6c95a7e7b1ae00929fb73e7ebee0f3faaeabad2f"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24a4146ccb15be237fdef10f331c568e1b0e505f8c8c9ed5d67759dac58ac246"}, + {file = "rpds_py-0.26.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a9a63785467b2d73635957d32a4f6e73d5e4df497a16a6392fa066b753e87387"}, + {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:de4ed93a8c91debfd5a047be327b7cc8b0cc6afe32a716bbbc4aedca9e2a83af"}, + {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:caf51943715b12af827696ec395bfa68f090a4c1a1d2509eb4e2cb69abbbdb33"}, + {file = "rpds_py-0.26.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4a59e5bc386de021f56337f757301b337d7ab58baa40174fb150accd480bc953"}, + {file = "rpds_py-0.26.0-cp314-cp314-win32.whl", hash = "sha256:92c8db839367ef16a662478f0a2fe13e15f2227da3c1430a782ad0f6ee009ec9"}, + {file = "rpds_py-0.26.0-cp314-cp314-win_amd64.whl", hash = "sha256:b0afb8cdd034150d4d9f53926226ed27ad15b7f465e93d7468caaf5eafae0d37"}, + {file = "rpds_py-0.26.0-cp314-cp314-win_arm64.whl", hash = "sha256:ca3f059f4ba485d90c8dc75cb5ca897e15325e4e609812ce57f896607c1c0867"}, + {file = "rpds_py-0.26.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5afea17ab3a126006dc2f293b14ffc7ef3c85336cf451564a0515ed7648033da"}, + {file = "rpds_py-0.26.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69f0c0a3df7fd3a7eec50a00396104bb9a843ea6d45fcc31c2d5243446ffd7a7"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:801a71f70f9813e82d2513c9a96532551fce1e278ec0c64610992c49c04c2dad"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df52098cde6d5e02fa75c1f6244f07971773adb4a26625edd5c18fee906fa84d"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9bc596b30f86dc6f0929499c9e574601679d0341a0108c25b9b358a042f51bca"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9dfbe56b299cf5875b68eb6f0ebaadc9cac520a1989cac0db0765abfb3709c19"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac64f4b2bdb4ea622175c9ab7cf09444e412e22c0e02e906978b3b488af5fde8"}, + {file = "rpds_py-0.26.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:181ef9b6bbf9845a264f9aa45c31836e9f3c1f13be565d0d010e964c661d1e2b"}, + {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:49028aa684c144ea502a8e847d23aed5e4c2ef7cadfa7d5eaafcb40864844b7a"}, + {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e5d524d68a474a9688336045bbf76cb0def88549c1b2ad9dbfec1fb7cfbe9170"}, + {file = "rpds_py-0.26.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c1851f429b822831bd2edcbe0cfd12ee9ea77868f8d3daf267b189371671c80e"}, + {file = "rpds_py-0.26.0-cp314-cp314t-win32.whl", hash = "sha256:7bdb17009696214c3b66bb3590c6d62e14ac5935e53e929bcdbc5a495987a84f"}, + {file = "rpds_py-0.26.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f14440b9573a6f76b4ee4770c13f0b5921f71dde3b6fcb8dabbefd13b7fe05d7"}, + {file = "rpds_py-0.26.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:7a48af25d9b3c15684059d0d1fc0bc30e8eee5ca521030e2bffddcab5be40226"}, + {file = "rpds_py-0.26.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0c71c2f6bf36e61ee5c47b2b9b5d47e4d1baad6426bfed9eea3e858fc6ee8806"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d815d48b1804ed7867b539236b6dd62997850ca1c91cad187f2ddb1b7bbef19"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:84cfbd4d4d2cdeb2be61a057a258d26b22877266dd905809e94172dff01a42ae"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fbaa70553ca116c77717f513e08815aec458e6b69a028d4028d403b3bc84ff37"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39bfea47c375f379d8e87ab4bb9eb2c836e4f2069f0f65731d85e55d74666387"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1533b7eb683fb5f38c1d68a3c78f5fdd8f1412fa6b9bf03b40f450785a0ab915"}, + {file = "rpds_py-0.26.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c5ab0ee51f560d179b057555b4f601b7df909ed31312d301b99f8b9fc6028284"}, + {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:e5162afc9e0d1f9cae3b577d9c29ddbab3505ab39012cb794d94a005825bde21"}, + {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:43f10b007033f359bc3fa9cd5e6c1e76723f056ffa9a6b5c117cc35720a80292"}, + {file = "rpds_py-0.26.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e3730a48e5622e598293eee0762b09cff34dd3f271530f47b0894891281f051d"}, + {file = "rpds_py-0.26.0-cp39-cp39-win32.whl", hash = "sha256:4b1f66eb81eab2e0ff5775a3a312e5e2e16bf758f7b06be82fb0d04078c7ac51"}, + {file = "rpds_py-0.26.0-cp39-cp39-win_amd64.whl", hash = "sha256:519067e29f67b5c90e64fb1a6b6e9d2ec0ba28705c51956637bac23a2f4ddae1"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3c0909c5234543ada2515c05dc08595b08d621ba919629e94427e8e03539c958"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:c1fb0cda2abcc0ac62f64e2ea4b4e64c57dfd6b885e693095460c61bde7bb18e"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84d142d2d6cf9b31c12aa4878d82ed3b2324226270b89b676ac62ccd7df52d08"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a547e21c5610b7e9093d870be50682a6a6cf180d6da0f42c47c306073bfdbbf6"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:35e9a70a0f335371275cdcd08bc5b8051ac494dd58bff3bbfb421038220dc871"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0dfa6115c6def37905344d56fb54c03afc49104e2ca473d5dedec0f6606913b4"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:313cfcd6af1a55a286a3c9a25f64af6d0e46cf60bc5798f1db152d97a216ff6f"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f7bf2496fa563c046d05e4d232d7b7fd61346e2402052064b773e5c378bf6f73"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:aa81873e2c8c5aa616ab8e017a481a96742fdf9313c40f14338ca7dbf50cb55f"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:68ffcf982715f5b5b7686bdd349ff75d422e8f22551000c24b30eaa1b7f7ae84"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6188de70e190847bb6db3dc3981cbadff87d27d6fe9b4f0e18726d55795cee9b"}, + {file = "rpds_py-0.26.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:1c962145c7473723df9722ba4c058de12eb5ebedcb4e27e7d902920aa3831ee8"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:f61a9326f80ca59214d1cceb0a09bb2ece5b2563d4e0cd37bfd5515c28510674"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:183f857a53bcf4b1b42ef0f57ca553ab56bdd170e49d8091e96c51c3d69ca696"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:941c1cfdf4799d623cf3aa1d326a6b4fdb7a5799ee2687f3516738216d2262fb"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72a8d9564a717ee291f554eeb4bfeafe2309d5ec0aa6c475170bdab0f9ee8e88"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:511d15193cbe013619dd05414c35a7dedf2088fcee93c6bbb7c77859765bd4e8"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:aea1f9741b603a8d8fedb0ed5502c2bc0accbc51f43e2ad1337fe7259c2b77a5"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4019a9d473c708cf2f16415688ef0b4639e07abaa569d72f74745bbeffafa2c7"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:093d63b4b0f52d98ebae33b8c50900d3d67e0666094b1be7a12fffd7f65de74b"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2abe21d8ba64cded53a2a677e149ceb76dcf44284202d737178afe7ba540c1eb"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:4feb7511c29f8442cbbc28149a92093d32e815a28aa2c50d333826ad2a20fdf0"}, + {file = "rpds_py-0.26.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:e99685fc95d386da368013e7fb4269dd39c30d99f812a8372d62f244f662709c"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:a90a13408a7a856b87be8a9f008fff53c5080eea4e4180f6c2e546e4a972fb5d"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:3ac51b65e8dc76cf4949419c54c5528adb24fc721df722fd452e5fbc236f5c40"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59b2093224a18c6508d95cfdeba8db9cbfd6f3494e94793b58972933fcee4c6d"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f01a5d6444a3258b00dc07b6ea4733e26f8072b788bef750baa37b370266137"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6e2c12160c72aeda9d1283e612f68804621f448145a210f1bf1d79151c47090"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cb28c1f569f8d33b2b5dcd05d0e6ef7005d8639c54c2f0be824f05aedf715255"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1766b5724c3f779317d5321664a343c07773c8c5fd1532e4039e6cc7d1a815be"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b6d9e5a2ed9c4988c8f9b28b3bc0e3e5b1aaa10c28d210a594ff3a8c02742daf"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:b5f7a446ddaf6ca0fad9a5535b56fbfc29998bf0e0b450d174bbec0d600e1d72"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:eed5ac260dd545fbc20da5f4f15e7efe36a55e0e7cf706e4ec005b491a9546a0"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:582462833ba7cee52e968b0341b85e392ae53d44c0f9af6a5927c80e539a8b67"}, + {file = "rpds_py-0.26.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:69a607203441e07e9a8a529cff1d5b73f6a160f22db1097211e6212a68567d11"}, + {file = "rpds_py-0.26.0.tar.gz", hash = "sha256:20dae58a859b0906f0685642e591056f1e787f3a8b39c8e8749a45dc7d26bdb0"}, ] [[package]] name = "rsa" -version = "4.9" +version = "4.9.1" description = "Pure-Python RSA implementation" optional = false -python-versions = ">=3.6,<4" +python-versions = "<4,>=3.6" +groups = ["main"] files = [ - {file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"}, - {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, + {file = "rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762"}, + {file = "rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75"}, ] [package.dependencies] @@ -3321,55 +5622,81 @@ pyasn1 = ">=0.1.3" [[package]] name = "ruff" -version = "0.8.3" +version = "0.12.4" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" +groups = ["dev"] +files = [ + {file = "ruff-0.12.4-py3-none-linux_armv6l.whl", hash = "sha256:cb0d261dac457ab939aeb247e804125a5d521b21adf27e721895b0d3f83a0d0a"}, + {file = "ruff-0.12.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:55c0f4ca9769408d9b9bac530c30d3e66490bd2beb2d3dae3e4128a1f05c7442"}, + {file = "ruff-0.12.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a8224cc3722c9ad9044da7f89c4c1ec452aef2cfe3904365025dd2f51daeae0e"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9949d01d64fa3672449a51ddb5d7548b33e130240ad418884ee6efa7a229586"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:be0593c69df9ad1465e8a2d10e3defd111fdb62dcd5be23ae2c06da77e8fcffb"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7dea966bcb55d4ecc4cc3270bccb6f87a337326c9dcd3c07d5b97000dbff41c"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:afcfa3ab5ab5dd0e1c39bf286d829e042a15e966b3726eea79528e2e24d8371a"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c057ce464b1413c926cdb203a0f858cd52f3e73dcb3270a3318d1630f6395bb3"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64b90d1122dc2713330350626b10d60818930819623abbb56535c6466cce045"}, + {file = "ruff-0.12.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2abc48f3d9667fdc74022380b5c745873499ff827393a636f7a59da1515e7c57"}, + {file = "ruff-0.12.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:2b2449dc0c138d877d629bea151bee8c0ae3b8e9c43f5fcaafcd0c0d0726b184"}, + {file = "ruff-0.12.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:56e45bb11f625db55f9b70477062e6a1a04d53628eda7784dce6e0f55fd549eb"}, + {file = "ruff-0.12.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:478fccdb82ca148a98a9ff43658944f7ab5ec41c3c49d77cd99d44da019371a1"}, + {file = "ruff-0.12.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0fc426bec2e4e5f4c4f182b9d2ce6a75c85ba9bcdbe5c6f2a74fcb8df437df4b"}, + {file = "ruff-0.12.4-py3-none-win32.whl", hash = "sha256:4de27977827893cdfb1211d42d84bc180fceb7b72471104671c59be37041cf93"}, + {file = "ruff-0.12.4-py3-none-win_amd64.whl", hash = "sha256:fe0b9e9eb23736b453143d72d2ceca5db323963330d5b7859d60d101147d461a"}, + {file = "ruff-0.12.4-py3-none-win_arm64.whl", hash = "sha256:0618ec4442a83ab545e5b71202a5c0ed7791e8471435b94e655b570a5031a98e"}, + {file = "ruff-0.12.4.tar.gz", hash = "sha256:13efa16df6c6eeb7d0f091abae50f58e9522f3843edb40d56ad52a5a4a4b6873"}, +] + +[[package]] +name = "secretstorage" +version = "3.3.3" +description = "Python bindings to FreeDesktop.org Secret Service API" +optional = false +python-versions = ">=3.6" +groups = ["main"] +markers = "sys_platform == \"linux\"" files = [ - {file = "ruff-0.8.3-py3-none-linux_armv6l.whl", hash = "sha256:8d5d273ffffff0acd3db5bf626d4b131aa5a5ada1276126231c4174543ce20d6"}, - {file = "ruff-0.8.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e4d66a21de39f15c9757d00c50c8cdd20ac84f55684ca56def7891a025d7e939"}, - {file = "ruff-0.8.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c356e770811858bd20832af696ff6c7e884701115094f427b64b25093d6d932d"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c0a60a825e3e177116c84009d5ebaa90cf40dfab56e1358d1df4e29a9a14b13"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:75fb782f4db39501210ac093c79c3de581d306624575eddd7e4e13747e61ba18"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7f26bc76a133ecb09a38b7868737eded6941b70a6d34ef53a4027e83913b6502"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:01b14b2f72a37390c1b13477c1c02d53184f728be2f3ffc3ace5b44e9e87b90d"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:53babd6e63e31f4e96ec95ea0d962298f9f0d9cc5990a1bbb023a6baf2503a82"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ae441ce4cf925b7f363d33cd6570c51435972d697e3e58928973994e56e1452"}, - {file = "ruff-0.8.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d7c65bc0cadce32255e93c57d57ecc2cca23149edd52714c0c5d6fa11ec328cd"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5be450bb18f23f0edc5a4e5585c17a56ba88920d598f04a06bd9fd76d324cb20"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8faeae3827eaa77f5721f09b9472a18c749139c891dbc17f45e72d8f2ca1f8fc"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:db503486e1cf074b9808403991663e4277f5c664d3fe237ee0d994d1305bb060"}, - {file = "ruff-0.8.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:6567be9fb62fbd7a099209257fef4ad2c3153b60579818b31a23c886ed4147ea"}, - {file = "ruff-0.8.3-py3-none-win32.whl", hash = "sha256:19048f2f878f3ee4583fc6cb23fb636e48c2635e30fb2022b3a1cd293402f964"}, - {file = "ruff-0.8.3-py3-none-win_amd64.whl", hash = "sha256:f7df94f57d7418fa7c3ffb650757e0c2b96cf2501a0b192c18e4fb5571dfada9"}, - {file = "ruff-0.8.3-py3-none-win_arm64.whl", hash = "sha256:fe2756edf68ea79707c8d68b78ca9a58ed9af22e430430491ee03e718b5e4936"}, - {file = "ruff-0.8.3.tar.gz", hash = "sha256:5e7558304353b84279042fc584a4f4cb8a07ae79b2bf3da1a7551d960b5626d3"}, + {file = "SecretStorage-3.3.3-py3-none-any.whl", hash = "sha256:f356e6628222568e3af06f2eba8df495efa13b3b63081dafd4f7d9a7b7bc9f99"}, + {file = "SecretStorage-3.3.3.tar.gz", hash = "sha256:2403533ef369eca6d2ba81718576c5e0f564d5cca1b58f73a8b23e7d4eeebd77"}, ] +[package.dependencies] +cryptography = ">=2.0" +jeepney = ">=0.6" + [[package]] name = "semver" -version = "3.0.2" +version = "3.0.4" description = "Python helper for Semantic Versioning (https://semver.org)" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ - {file = "semver-3.0.2-py3-none-any.whl", hash = "sha256:b1ea4686fe70b981f85359eda33199d60c53964284e0cfb4977d243e37cf4bf4"}, - {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, + {file = "semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746"}, + {file = "semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602"}, ] [[package]] name = "sentry-sdk" -version = "2.19.2" +version = "2.33.2" description = "Python client for Sentry (https://sentry.io)" optional = false python-versions = ">=3.6" +groups = ["main"] files = [ - {file = "sentry_sdk-2.19.2-py2.py3-none-any.whl", hash = "sha256:ebdc08228b4d131128e568d696c210d846e5b9d70aa0327dec6b1272d9d40b84"}, - {file = "sentry_sdk-2.19.2.tar.gz", hash = "sha256:467df6e126ba242d39952375dd816fbee0f217d119bf454a8ce74cf1e7909e8d"}, + {file = "sentry_sdk-2.33.2-py2.py3-none-any.whl", hash = "sha256:8d57a3b4861b243aa9d558fda75509ad487db14f488cbdb6c78c614979d77632"}, + {file = "sentry_sdk-2.33.2.tar.gz", hash = "sha256:e85002234b7b8efac9b74c2d91dbd4f8f3970dc28da8798e39530e65cb740f94"}, ] [package.dependencies] +anthropic = {version = ">=0.16", optional = true, markers = "extra == \"anthropic\""} certifi = "*" +fastapi = {version = ">=0.79.0", optional = true, markers = "extra == \"fastapi\""} +launchdarkly-server-sdk = {version = ">=9.8.0", optional = true, markers = "extra == \"launchdarkly\""} +openai = {version = ">=1.0.0", optional = true, markers = "extra == \"openai\""} +sqlalchemy = {version = ">=1.2", optional = true, markers = "extra == \"sqlalchemy\""} +tiktoken = {version = ">=0.3.0", optional = true, markers = "extra == \"openai\""} urllib3 = ">=1.26.11" [package.extras] @@ -3409,35 +5736,61 @@ sanic = ["sanic (>=0.8)"] sqlalchemy = ["sqlalchemy (>=1.2)"] starlette = ["starlette (>=0.19.1)"] starlite = ["starlite (>=1.48)"] +statsig = ["statsig (>=0.55.3)"] tornado = ["tornado (>=6)"] +unleash = ["UnleashClient (>=6.0.1)"] [[package]] -name = "serpent" -version = "1.41" -description = "Serialization based on ast.literal_eval" +name = "setuptools" +version = "80.9.0" +description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=3.2" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "serpent-1.41-py3-none-any.whl", hash = "sha256:5fd776b3420441985bc10679564c2c9b4a19f77bea59f018e473441d98ae5dd7"}, - {file = "serpent-1.41.tar.gz", hash = "sha256:0407035fe3c6644387d48cff1467d5aa9feff814d07372b78677ed0ee3ed7095"}, + {file = "setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922"}, + {file = "setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c"}, ] +[package.extras] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\"", "ruff (>=0.8.0) ; sys_platform != \"cygwin\""] +core = ["importlib_metadata (>=6) ; python_version < \"3.10\"", "jaraco.functools (>=4)", "jaraco.text (>=3.7)", "more_itertools", "more_itertools (>=8.8)", "packaging (>=24.2)", "platformdirs (>=4.2.2)", "tomli (>=2.0.1) ; python_version < \"3.11\"", "wheel (>=0.43.0)"] +cover = ["pytest-cov"] +doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +enabler = ["pytest-enabler (>=2.2)"] +test = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21) ; python_version >= \"3.9\" and sys_platform != \"cygwin\"", "jaraco.envs (>=2.2)", "jaraco.path (>=3.7.2)", "jaraco.test (>=5.5)", "packaging (>=24.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.*)", "pytest-home (>=0.5)", "pytest-perf ; sys_platform != \"cygwin\"", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel (>=0.44.0)"] +type = ["importlib_metadata (>=7.0.2) ; python_version < \"3.10\"", "jaraco.develop (>=7.21) ; sys_platform != \"cygwin\"", "mypy (==1.14.*)", "pytest-mypy"] + [[package]] name = "sgmllib3k" version = "1.0.0" description = "Py3k port of sgmllib." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "sgmllib3k-1.0.0.tar.gz", hash = "sha256:7868fb1c8bfa764c1ac563d3cf369c381d1325d36124933a726f29fcdaa812e9"}, ] +[[package]] +name = "shellingham" +version = "1.5.4" +description = "Tool to Detect Surrounding Shell" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686"}, + {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, +] + [[package]] name = "six" version = "1.17.0" description = "Python 2 and 3 compatibility utilities" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" +groups = ["main"] files = [ {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, @@ -3449,6 +5802,7 @@ version = "1.3.1" description = "Sniff out which async library your code is running under" optional = false python-versions = ">=3.7" +groups = ["main", "dev"] files = [ {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, @@ -3456,80 +5810,81 @@ files = [ [[package]] name = "sqlalchemy" -version = "2.0.36" +version = "2.0.41" description = "Database Abstraction Library" optional = false python-versions = ">=3.7" -files = [ - {file = "SQLAlchemy-2.0.36-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:59b8f3adb3971929a3e660337f5dacc5942c2cdb760afcabb2614ffbda9f9f72"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37350015056a553e442ff672c2d20e6f4b6d0b2495691fa239d8aa18bb3bc908"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8318f4776c85abc3f40ab185e388bee7a6ea99e7fa3a30686580b209eaa35c08"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c245b1fbade9c35e5bd3b64270ab49ce990369018289ecfde3f9c318411aaa07"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:69f93723edbca7342624d09f6704e7126b152eaed3cdbb634cb657a54332a3c5"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f9511d8dd4a6e9271d07d150fb2f81874a3c8c95e11ff9af3a2dfc35fe42ee44"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-win32.whl", hash = "sha256:c3f3631693003d8e585d4200730616b78fafd5a01ef8b698f6967da5c605b3fa"}, - {file = "SQLAlchemy-2.0.36-cp310-cp310-win_amd64.whl", hash = "sha256:a86bfab2ef46d63300c0f06936bd6e6c0105faa11d509083ba8f2f9d237fb5b5"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fd3a55deef00f689ce931d4d1b23fa9f04c880a48ee97af488fd215cf24e2a6c"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4f5e9cd989b45b73bd359f693b935364f7e1f79486e29015813c338450aa5a71"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ddd9db6e59c44875211bc4c7953a9f6638b937b0a88ae6d09eb46cced54eff"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2519f3a5d0517fc159afab1015e54bb81b4406c278749779be57a569d8d1bb0d"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:59b1ee96617135f6e1d6f275bbe988f419c5178016f3d41d3c0abb0c819f75bb"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:39769a115f730d683b0eb7b694db9789267bcd027326cccc3125e862eb03bfd8"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-win32.whl", hash = "sha256:66bffbad8d6271bb1cc2f9a4ea4f86f80fe5e2e3e501a5ae2a3dc6a76e604e6f"}, - {file = "SQLAlchemy-2.0.36-cp311-cp311-win_amd64.whl", hash = "sha256:23623166bfefe1487d81b698c423f8678e80df8b54614c2bf4b4cfcd7c711959"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f7b64e6ec3f02c35647be6b4851008b26cff592a95ecb13b6788a54ef80bbdd4"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46331b00096a6db1fdc052d55b101dbbfc99155a548e20a0e4a8e5e4d1362855"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdf3386a801ea5aba17c6410dd1dc8d39cf454ca2565541b5ac42a84e1e28f53"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac9dfa18ff2a67b09b372d5db8743c27966abf0e5344c555d86cc7199f7ad83a"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:90812a8933df713fdf748b355527e3af257a11e415b613dd794512461eb8a686"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1bc330d9d29c7f06f003ab10e1eaced295e87940405afe1b110f2eb93a233588"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-win32.whl", hash = "sha256:79d2e78abc26d871875b419e1fd3c0bca31a1cb0043277d0d850014599626c2e"}, - {file = "SQLAlchemy-2.0.36-cp312-cp312-win_amd64.whl", hash = "sha256:b544ad1935a8541d177cb402948b94e871067656b3a0b9e91dbec136b06a2ff5"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5cc79df7f4bc3d11e4b542596c03826063092611e481fcf1c9dfee3c94355ef"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3c01117dd36800f2ecaa238c65365b7b16497adc1522bf84906e5710ee9ba0e8"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9bc633f4ee4b4c46e7adcb3a9b5ec083bf1d9a97c1d3854b92749d935de40b9b"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e46ed38affdfc95d2c958de328d037d87801cfcbea6d421000859e9789e61c2"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b2985c0b06e989c043f1dc09d4fe89e1616aadd35392aea2844f0458a989eacf"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a121d62ebe7d26fec9155f83f8be5189ef1405f5973ea4874a26fab9f1e262c"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-win32.whl", hash = "sha256:0572f4bd6f94752167adfd7c1bed84f4b240ee6203a95e05d1e208d488d0d436"}, - {file = "SQLAlchemy-2.0.36-cp313-cp313-win_amd64.whl", hash = "sha256:8c78ac40bde930c60e0f78b3cd184c580f89456dd87fc08f9e3ee3ce8765ce88"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:be9812b766cad94a25bc63bec11f88c4ad3629a0cec1cd5d4ba48dc23860486b"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50aae840ebbd6cdd41af1c14590e5741665e5272d2fee999306673a1bb1fdb4d"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4557e1f11c5f653ebfdd924f3f9d5ebfc718283b0b9beebaa5dd6b77ec290971"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:07b441f7d03b9a66299ce7ccf3ef2900abc81c0db434f42a5694a37bd73870f2"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:28120ef39c92c2dd60f2721af9328479516844c6b550b077ca450c7d7dc68575"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-win32.whl", hash = "sha256:b81ee3d84803fd42d0b154cb6892ae57ea6b7c55d8359a02379965706c7efe6c"}, - {file = "SQLAlchemy-2.0.36-cp37-cp37m-win_amd64.whl", hash = "sha256:f942a799516184c855e1a32fbc7b29d7e571b52612647866d4ec1c3242578fcb"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:3d6718667da04294d7df1670d70eeddd414f313738d20a6f1d1f379e3139a545"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:72c28b84b174ce8af8504ca28ae9347d317f9dba3999e5981a3cd441f3712e24"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b11d0cfdd2b095e7b0686cf5fabeb9c67fae5b06d265d8180715b8cfa86522e3"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e32092c47011d113dc01ab3e1d3ce9f006a47223b18422c5c0d150af13a00687"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:6a440293d802d3011028e14e4226da1434b373cbaf4a4bbb63f845761a708346"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:c54a1e53a0c308a8e8a7dffb59097bff7facda27c70c286f005327f21b2bd6b1"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-win32.whl", hash = "sha256:1e0d612a17581b6616ff03c8e3d5eff7452f34655c901f75d62bd86449d9750e"}, - {file = "SQLAlchemy-2.0.36-cp38-cp38-win_amd64.whl", hash = "sha256:8958b10490125124463095bbdadda5aa22ec799f91958e410438ad6c97a7b793"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:dc022184d3e5cacc9579e41805a681187650e170eb2fd70e28b86192a479dcaa"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b817d41d692bf286abc181f8af476c4fbef3fd05e798777492618378448ee689"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4e46a888b54be23d03a89be510f24a7652fe6ff660787b96cd0e57a4ebcb46d"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c4ae3005ed83f5967f961fd091f2f8c5329161f69ce8480aa8168b2d7fe37f06"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:03e08af7a5f9386a43919eda9de33ffda16b44eb11f3b313e6822243770e9763"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb986bad3ed5ceaf090200eba750b5245150bd97d3e67343a3cfed06feecf7"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-win32.whl", hash = "sha256:9fe53b404f24789b5ea9003fc25b9a3988feddebd7e7b369c8fac27ad6f52f28"}, - {file = "SQLAlchemy-2.0.36-cp39-cp39-win_amd64.whl", hash = "sha256:af148a33ff0349f53512a049c6406923e4e02bf2f26c5fb285f143faf4f0e46a"}, - {file = "SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e"}, - {file = "sqlalchemy-2.0.36.tar.gz", hash = "sha256:7f2767680b6d2398aea7082e45a774b2b0767b5c8d8ffb9c8b683088ea9b29c5"}, -] - -[package.dependencies] -greenlet = {version = "!=0.4.17", markers = "python_version < \"3.13\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} +groups = ["main"] +files = [ + {file = "SQLAlchemy-2.0.41-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6854175807af57bdb6425e47adbce7d20a4d79bbfd6f6d6519cd10bb7109a7f8"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:05132c906066142103b83d9c250b60508af556982a385d96c4eaa9fb9720ac2b"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b4af17bda11e907c51d10686eda89049f9ce5669b08fbe71a29747f1e876036"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:c0b0e5e1b5d9f3586601048dd68f392dc0cc99a59bb5faf18aab057ce00d00b2"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0b3dbf1e7e9bc95f4bac5e2fb6d3fb2f083254c3fdd20a1789af965caf2d2348"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-win32.whl", hash = "sha256:1e3f196a0c59b0cae9a0cd332eb1a4bda4696e863f4f1cf84ab0347992c548c2"}, + {file = "SQLAlchemy-2.0.41-cp37-cp37m-win_amd64.whl", hash = "sha256:6ab60a5089a8f02009f127806f777fca82581c49e127f08413a66056bd9166dd"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1f09b6821406ea1f94053f346f28f8215e293344209129a9c0fcc3578598d7b"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1936af879e3db023601196a1684d28e12f19ccf93af01bf3280a3262c4b6b4e5"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b2ac41acfc8d965fb0c464eb8f44995770239668956dc4cdf502d1b1ffe0d747"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81c24e0c0fde47a9723c81d5806569cddef103aebbf79dbc9fcbb617153dea30"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23a8825495d8b195c4aa9ff1c430c28f2c821e8c5e2d98089228af887e5d7e29"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:60c578c45c949f909a4026b7807044e7e564adf793537fc762b2489d522f3d11"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-win32.whl", hash = "sha256:118c16cd3f1b00c76d69343e38602006c9cfb9998fa4f798606d28d63f23beda"}, + {file = "sqlalchemy-2.0.41-cp310-cp310-win_amd64.whl", hash = "sha256:7492967c3386df69f80cf67efd665c0f667cee67032090fe01d7d74b0e19bb08"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6375cd674fe82d7aa9816d1cb96ec592bac1726c11e0cafbf40eeee9a4516b5f"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9f8c9fdd15a55d9465e590a402f42082705d66b05afc3ffd2d2eb3c6ba919560"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32f9dc8c44acdee06c8fc6440db9eae8b4af8b01e4b1aee7bdd7241c22edff4f"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90c11ceb9a1f482c752a71f203a81858625d8df5746d787a4786bca4ffdf71c6"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:911cc493ebd60de5f285bcae0491a60b4f2a9f0f5c270edd1c4dbaef7a38fc04"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:03968a349db483936c249f4d9cd14ff2c296adfa1290b660ba6516f973139582"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-win32.whl", hash = "sha256:293cd444d82b18da48c9f71cd7005844dbbd06ca19be1ccf6779154439eec0b8"}, + {file = "sqlalchemy-2.0.41-cp311-cp311-win_amd64.whl", hash = "sha256:3d3549fc3e40667ec7199033a4e40a2f669898a00a7b18a931d3efb4c7900504"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:81f413674d85cfd0dfcd6512e10e0f33c19c21860342a4890c3a2b59479929f9"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:598d9ebc1e796431bbd068e41e4de4dc34312b7aa3292571bb3674a0cb415dd1"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a104c5694dfd2d864a6f91b0956eb5d5883234119cb40010115fd45a16da5e70"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6145afea51ff0af7f2564a05fa95eb46f542919e6523729663a5d285ecb3cf5e"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b46fa6eae1cd1c20e6e6f44e19984d438b6b2d8616d21d783d150df714f44078"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41836fe661cc98abfae476e14ba1906220f92c4e528771a8a3ae6a151242d2ae"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-win32.whl", hash = "sha256:a8808d5cf866c781150d36a3c8eb3adccfa41a8105d031bf27e92c251e3969d6"}, + {file = "sqlalchemy-2.0.41-cp312-cp312-win_amd64.whl", hash = "sha256:5b14e97886199c1f52c14629c11d90c11fbb09e9334fa7bb5f6d068d9ced0ce0"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4eeb195cdedaf17aab6b247894ff2734dcead6c08f748e617bfe05bd5a218443"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d4ae769b9c1c7757e4ccce94b0641bc203bbdf43ba7a2413ab2523d8d047d8dc"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a62448526dd9ed3e3beedc93df9bb6b55a436ed1474db31a2af13b313a70a7e1"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc56c9788617b8964ad02e8fcfeed4001c1f8ba91a9e1f31483c0dffb207002a"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c153265408d18de4cc5ded1941dcd8315894572cddd3c58df5d5b5705b3fa28d"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f67766965996e63bb46cfbf2ce5355fc32d9dd3b8ad7e536a920ff9ee422e23"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-win32.whl", hash = "sha256:bfc9064f6658a3d1cadeaa0ba07570b83ce6801a1314985bf98ec9b95d74e15f"}, + {file = "sqlalchemy-2.0.41-cp313-cp313-win_amd64.whl", hash = "sha256:82ca366a844eb551daff9d2e6e7a9e5e76d2612c8564f58db6c19a726869c1df"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:90144d3b0c8b139408da50196c5cad2a6909b51b23df1f0538411cd23ffa45d3"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:023b3ee6169969beea3bb72312e44d8b7c27c75b347942d943cf49397b7edeb5"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:725875a63abf7c399d4548e686debb65cdc2549e1825437096a0af1f7e374814"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81965cc20848ab06583506ef54e37cf15c83c7e619df2ad16807c03100745dea"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dd5ec3aa6ae6e4d5b5de9357d2133c07be1aff6405b136dad753a16afb6717dd"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:ff8e80c4c4932c10493ff97028decfdb622de69cae87e0f127a7ebe32b4069c6"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-win32.whl", hash = "sha256:4d44522480e0bf34c3d63167b8cfa7289c1c54264c2950cc5fc26e7850967e45"}, + {file = "sqlalchemy-2.0.41-cp38-cp38-win_amd64.whl", hash = "sha256:81eedafa609917040d39aa9332e25881a8e7a0862495fcdf2023a9667209deda"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9a420a91913092d1e20c86a2f5f1fc85c1a8924dbcaf5e0586df8aceb09c9cc2"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:906e6b0d7d452e9a98e5ab8507c0da791856b2380fdee61b765632bb8698026f"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a373a400f3e9bac95ba2a06372c4fd1412a7cee53c37fc6c05f829bf672b8769"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:087b6b52de812741c27231b5a3586384d60c353fbd0e2f81405a814b5591dc8b"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:34ea30ab3ec98355235972dadc497bb659cc75f8292b760394824fab9cf39826"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:8280856dd7c6a68ab3a164b4a4b1c51f7691f6d04af4d4ca23d6ecf2261b7923"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-win32.whl", hash = "sha256:b50eab9994d64f4a823ff99a0ed28a6903224ddbe7fef56a6dd865eec9243440"}, + {file = "sqlalchemy-2.0.41-cp39-cp39-win_amd64.whl", hash = "sha256:5e22575d169529ac3e0a120cf050ec9daa94b6a9597993d1702884f6954a7d71"}, + {file = "sqlalchemy-2.0.41-py3-none-any.whl", hash = "sha256:57df5dc6fdb5ed1a88a1ed2195fd31927e705cad62dedd86b46972752a80f576"}, + {file = "sqlalchemy-2.0.41.tar.gz", hash = "sha256:edba70118c4be3c2b1f90754d308d0b79c6fe2c0fdc52d8ddf603916f83f4db9"}, +] + +[package.dependencies] +greenlet = {version = ">=1", markers = "python_version < \"3.14\" and (platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\")"} typing-extensions = ">=4.6.0" [package.extras] -aiomysql = ["aiomysql (>=0.2.0)", "greenlet (!=0.4.17)"] -aioodbc = ["aioodbc", "greenlet (!=0.4.17)"] -aiosqlite = ["aiosqlite", "greenlet (!=0.4.17)", "typing_extensions (!=3.10.0.1)"] -asyncio = ["greenlet (!=0.4.17)"] -asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (!=0.4.17)"] +aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"] +aioodbc = ["aioodbc", "greenlet (>=1)"] +aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"] +asyncio = ["greenlet (>=1)"] +asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"] mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"] mssql = ["pyodbc"] mssql-pymssql = ["pymssql"] @@ -3540,7 +5895,7 @@ mysql-connector = ["mysql-connector-python"] oracle = ["cx_oracle (>=8)"] oracle-oracledb = ["oracledb (>=1.0.1)"] postgresql = ["psycopg2 (>=2.7)"] -postgresql-asyncpg = ["asyncpg", "greenlet (!=0.4.17)"] +postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"] postgresql-pg8000 = ["pg8000 (>=1.29.1)"] postgresql-psycopg = ["psycopg (>=3.0.7)"] postgresql-psycopg2binary = ["psycopg2-binary"] @@ -3549,36 +5904,68 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "stagehand" +version = "0.5.1" +description = "Python SDK for Stagehand" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "stagehand-0.5.1-py3-none-any.whl", hash = "sha256:97f88ef31c4b2ad448c2ef341a3cff074b83e40c60872d8f49f79c82f9249174"}, + {file = "stagehand-0.5.1.tar.gz", hash = "sha256:312611f776a5f93f3a10a3cae87cd4881eb020c999a87394b21bff2b123fdfc3"}, +] + +[package.dependencies] +anthropic = ">=0.51.0" +browserbase = ">=1.4.0" +httpx = ">=0.24.0" +litellm = ">=1.72.0,<1.75.0" +nest-asyncio = ">=1.6.0" +openai = ">=1.83.0,<1.99.6" +playwright = ">=1.42.1" +pydantic = ">=1.10.0" +python-dotenv = ">=1.0.0" +requests = ">=2.31.0" +rich = ">=13.7.0" + +[package.extras] +dev = ["black (>=23.3.0)", "isort (>=5.12.0)", "mypy (>=1.3.0)", "psutil (>=5.9.0)", "pytest (>=7.3.1)", "pytest-asyncio (>=0.21.0)", "pytest-cov (>=4.1.0)", "pytest-mock (>=3.10.0)", "ruff"] + [[package]] name = "starlette" -version = "0.41.3" +version = "0.47.1" description = "The little ASGI library that shines." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "starlette-0.41.3-py3-none-any.whl", hash = "sha256:44cedb2b7c77a9de33a8b74b2b90e9f50d11fcf25d8270ea525ad71a25374ff7"}, - {file = "starlette-0.41.3.tar.gz", hash = "sha256:0e4ab3d16522a255be6b28260b938eae2482f98ce5cc934cb08dce8dc3ba5835"}, + {file = "starlette-0.47.1-py3-none-any.whl", hash = "sha256:5e11c9f5c7c3f24959edbf2dffdc01bba860228acf657129467d8a7468591527"}, + {file = "starlette-0.47.1.tar.gz", hash = "sha256:aef012dd2b6be325ffa16698f9dc533614fb1cebd593a906b90dc1025529a79b"}, ] [package.dependencies] -anyio = ">=3.4.0,<5" +anyio = ">=3.6.2,<5" +typing-extensions = {version = ">=4.10.0", markers = "python_version < \"3.13\""} [package.extras] -full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.7)", "pyyaml"] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] [[package]] name = "storage3" -version = "0.9.0" +version = "0.12.0" description = "Supabase Storage client for Python." optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "storage3-0.9.0-py3-none-any.whl", hash = "sha256:8b2fb91f0c61583a2f4eac74a8bae67e00d41ff38095c8a6cd3f2ce5e0ab76e7"}, - {file = "storage3-0.9.0.tar.gz", hash = "sha256:e16697f60894c94e1d9df0d2e4af783c1b3f7dd08c9013d61978825c624188c4"}, + {file = "storage3-0.12.0-py3-none-any.whl", hash = "sha256:1c4585693ca42243ded1512b58e54c697111e91a20916cd14783eebc37e7c87d"}, + {file = "storage3-0.12.0.tar.gz", hash = "sha256:94243f20922d57738bf42e96b9f5582b4d166e8bf209eccf20b146909f3f71b0"}, ] [package.dependencies] -httpx = {version = ">=0.26,<0.28", extras = ["http2"]} +deprecation = ">=2.1.0,<3.0.0" +httpx = {version = ">=0.26,<0.29", extras = ["http2"]} python-dateutil = ">=2.8.2,<3.0.0" [[package]] @@ -3587,6 +5974,7 @@ version = "0.4.15" description = "An Enum that inherits from str." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659"}, {file = "StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff"}, @@ -3597,60 +5985,197 @@ docs = ["myst-parser[linkify]", "sphinx", "sphinx-rtd-theme"] release = ["twine"] test = ["pylint", "pytest", "pytest-black", "pytest-cov", "pytest-pylint"] +[[package]] +name = "stripe" +version = "11.6.0" +description = "Python bindings for the Stripe API" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "stripe-11.6.0-py2.py3-none-any.whl", hash = "sha256:6e6cf09ebb6d5fc2d708401cb8868fd7bff987a6d09a0433caaa92c62f97dbc5"}, + {file = "stripe-11.6.0.tar.gz", hash = "sha256:0ced7cce23a6cb1a393c86a1f7f9435c9d83ae7cbd556362868caf62cb44a92c"}, +] + +[package.dependencies] +requests = {version = ">=2.20", markers = "python_version >= \"3.0\""} +typing-extensions = {version = ">=4.5.0", markers = "python_version >= \"3.7\""} + [[package]] name = "supabase" -version = "2.10.0" +version = "2.17.0" description = "Supabase client for Python." optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "supabase-2.10.0-py3-none-any.whl", hash = "sha256:183fb23c04528593f8f81c24ceb8178f3a56bff40fec7ed873b6c55ebc2e420a"}, - {file = "supabase-2.10.0.tar.gz", hash = "sha256:9ac095f8947bf60780e67c0edcbab53e2db3f6f3f022329397b093500bf2607c"}, + {file = "supabase-2.17.0-py3-none-any.whl", hash = "sha256:2dd804fae8850cebccc9ab8711c2ee9e2f009e847f4c95c092a4423778e3c3f6"}, + {file = "supabase-2.17.0.tar.gz", hash = "sha256:3207314b540db7e3339fa2500bd977541517afb4d20b7ff93a89b97a05f9df38"}, ] [package.dependencies] -gotrue = ">=2.10.0,<3.0.0" -httpx = ">=0.26,<0.28" -postgrest = ">=0.18,<0.19" -realtime = ">=2.0.0,<3.0.0" -storage3 = ">=0.9.0,<0.10.0" -supafunc = ">=0.7.0,<0.8.0" +gotrue = "2.12.3" +httpx = ">=0.26,<0.29" +postgrest = "1.1.1" +realtime = "2.6.0" +storage3 = "0.12.0" +supafunc = "0.10.1" [[package]] name = "supafunc" -version = "0.7.0" +version = "0.10.1" description = "Library for Supabase Functions" optional = false python-versions = "<4.0,>=3.9" +groups = ["main"] files = [ - {file = "supafunc-0.7.0-py3-none-any.whl", hash = "sha256:4160260dc02bdd906be1e2ffd7cb3ae8b74ae437c892bb475352b6a99d9ff8eb"}, - {file = "supafunc-0.7.0.tar.gz", hash = "sha256:5b1c415fba1395740b2b4eedd1d786384bd58b98f6333a11ba7889820a48b6a7"}, + {file = "supafunc-0.10.1-py3-none-any.whl", hash = "sha256:26df9bd25ff2ef56cb5bfb8962de98f43331f7f8ff69572bac3ed9c3a9672040"}, + {file = "supafunc-0.10.1.tar.gz", hash = "sha256:a5b33c8baecb6b5297d25da29a2503e2ec67ee6986f3d44c137e651b8a59a17d"}, ] [package.dependencies] -httpx = {version = ">=0.26,<0.28", extras = ["http2"]} +httpx = {version = ">=0.26,<0.29", extras = ["http2"]} +strenum = ">=0.4.15,<0.5.0" [[package]] name = "tenacity" -version = "9.0.0" +version = "9.1.2" description = "Retry code until it succeeds" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "tenacity-9.0.0-py3-none-any.whl", hash = "sha256:93de0c98785b27fcf659856aa9f54bfbd399e29969b0621bc7f762bd441b4539"}, - {file = "tenacity-9.0.0.tar.gz", hash = "sha256:807f37ca97d62aa361264d497b0e31e92b8027044942bfa756160d908320d73b"}, + {file = "tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138"}, + {file = "tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb"}, ] [package.extras] doc = ["reno", "sphinx"] test = ["pytest", "tornado (>=4.5)", "typeguard"] +[[package]] +name = "tiktoken" +version = "0.9.0" +description = "tiktoken is a fast BPE tokeniser for use with OpenAI's models" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tiktoken-0.9.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:586c16358138b96ea804c034b8acf3f5d3f0258bd2bc3b0227af4af5d622e382"}, + {file = "tiktoken-0.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9c59ccc528c6c5dd51820b3474402f69d9a9e1d656226848ad68a8d5b2e5108"}, + {file = "tiktoken-0.9.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0968d5beeafbca2a72c595e8385a1a1f8af58feaebb02b227229b69ca5357fd"}, + {file = "tiktoken-0.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:92a5fb085a6a3b7350b8fc838baf493317ca0e17bd95e8642f95fc69ecfed1de"}, + {file = "tiktoken-0.9.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:15a2752dea63d93b0332fb0ddb05dd909371ededa145fe6a3242f46724fa7990"}, + {file = "tiktoken-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:26113fec3bd7a352e4b33dbaf1bd8948de2507e30bd95a44e2b1156647bc01b4"}, + {file = "tiktoken-0.9.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f32cc56168eac4851109e9b5d327637f15fd662aa30dd79f964b7c39fbadd26e"}, + {file = "tiktoken-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:45556bc41241e5294063508caf901bf92ba52d8ef9222023f83d2483a3055348"}, + {file = "tiktoken-0.9.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03935988a91d6d3216e2ec7c645afbb3d870b37bcb67ada1943ec48678e7ee33"}, + {file = "tiktoken-0.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8b3d80aad8d2c6b9238fc1a5524542087c52b860b10cbf952429ffb714bc1136"}, + {file = "tiktoken-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b2a21133be05dc116b1d0372af051cd2c6aa1d2188250c9b553f9fa49301b336"}, + {file = "tiktoken-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:11a20e67fdf58b0e2dea7b8654a288e481bb4fc0289d3ad21291f8d0849915fb"}, + {file = "tiktoken-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e88f121c1c22b726649ce67c089b90ddda8b9662545a8aeb03cfef15967ddd03"}, + {file = "tiktoken-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a6600660f2f72369acb13a57fb3e212434ed38b045fd8cc6cdd74947b4b5d210"}, + {file = "tiktoken-0.9.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95e811743b5dfa74f4b227927ed86cbc57cad4df859cb3b643be797914e41794"}, + {file = "tiktoken-0.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:99376e1370d59bcf6935c933cb9ba64adc29033b7e73f5f7569f3aad86552b22"}, + {file = "tiktoken-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:badb947c32739fb6ddde173e14885fb3de4d32ab9d8c591cbd013c22b4c31dd2"}, + {file = "tiktoken-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:5a62d7a25225bafed786a524c1b9f0910a1128f4232615bf3f8257a73aaa3b16"}, + {file = "tiktoken-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b0e8e05a26eda1249e824156d537015480af7ae222ccb798e5234ae0285dbdb"}, + {file = "tiktoken-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:27d457f096f87685195eea0165a1807fae87b97b2161fe8c9b1df5bd74ca6f63"}, + {file = "tiktoken-0.9.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cf8ded49cddf825390e36dd1ad35cd49589e8161fdcb52aa25f0583e90a3e01"}, + {file = "tiktoken-0.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc156cb314119a8bb9748257a2eaebd5cc0753b6cb491d26694ed42fc7cb3139"}, + {file = "tiktoken-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cd69372e8c9dd761f0ab873112aba55a0e3e506332dd9f7522ca466e817b1b7a"}, + {file = "tiktoken-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5ea0edb6f83dc56d794723286215918c1cde03712cbbafa0348b33448faf5b95"}, + {file = "tiktoken-0.9.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c6386ca815e7d96ef5b4ac61e0048cd32ca5a92d5781255e13b31381d28667dc"}, + {file = "tiktoken-0.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:75f6d5db5bc2c6274b674ceab1615c1778e6416b14705827d19b40e6355f03e0"}, + {file = "tiktoken-0.9.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e15b16f61e6f4625a57a36496d28dd182a8a60ec20a534c5343ba3cafa156ac7"}, + {file = "tiktoken-0.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ebcec91babf21297022882344c3f7d9eed855931466c3311b1ad6b64befb3df"}, + {file = "tiktoken-0.9.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e5fd49e7799579240f03913447c0cdfa1129625ebd5ac440787afc4345990427"}, + {file = "tiktoken-0.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:26242ca9dc8b58e875ff4ca078b9a94d2f0813e6a535dcd2205df5d49d927cc7"}, + {file = "tiktoken-0.9.0.tar.gz", hash = "sha256:d02a5ca6a938e0490e1ff957bc48c8b078c88cb83977be1625b1fd8aac792c5d"}, +] + +[package.dependencies] +regex = ">=2022.1.18" +requests = ">=2.26.0" + +[package.extras] +blobfile = ["blobfile (>=2)"] + +[[package]] +name = "tinycss2" +version = "1.4.0" +description = "A tiny CSS parser" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289"}, + {file = "tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7"}, +] + +[package.dependencies] +webencodings = ">=0.4" + +[package.extras] +doc = ["sphinx", "sphinx_rtd_theme"] +test = ["pytest", "ruff"] + +[[package]] +name = "todoist-api-python" +version = "2.1.7" +description = "Official Python SDK for the Todoist REST API." +optional = false +python-versions = "<4.0,>=3.9" +groups = ["main"] +files = [ + {file = "todoist_api_python-2.1.7-py3-none-any.whl", hash = "sha256:278bfe851b9bd19bde5ff5de09d813d671ef7310ba55e1962131fca5b59bb735"}, + {file = "todoist_api_python-2.1.7.tar.gz", hash = "sha256:84934a19ccd83fb61010a8126362a5d7d6486c92454c111307ba55bc74903f5c"}, +] + +[package.dependencies] +requests = ">=2.32.3,<3.0.0" + +[[package]] +name = "tokenizers" +version = "0.21.4" +description = "" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tokenizers-0.21.4-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ccc10a7c3bcefe0f242867dc914fc1226ee44321eb618cfe3019b5df3400133"}, + {file = "tokenizers-0.21.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:5e2f601a8e0cd5be5cc7506b20a79112370b9b3e9cb5f13f68ab11acd6ca7d60"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:39b376f5a1aee67b4d29032ee85511bbd1b99007ec735f7f35c8a2eb104eade5"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2107ad649e2cda4488d41dfd031469e9da3fcbfd6183e74e4958fa729ffbf9c6"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c73012da95afafdf235ba80047699df4384fdc481527448a078ffd00e45a7d9"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f23186c40395fc390d27f519679a58023f368a0aad234af145e0f39ad1212732"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc88bb34e23a54cc42713d6d98af5f1bf79c07653d24fe984d2d695ba2c922a2"}, + {file = "tokenizers-0.21.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51b7eabb104f46c1c50b486520555715457ae833d5aee9ff6ae853d1130506ff"}, + {file = "tokenizers-0.21.4-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:714b05b2e1af1288bd1bc56ce496c4cebb64a20d158ee802887757791191e6e2"}, + {file = "tokenizers-0.21.4-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1340ff877ceedfa937544b7d79f5b7becf33a4cfb58f89b3b49927004ef66f78"}, + {file = "tokenizers-0.21.4-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3c1f4317576e465ac9ef0d165b247825a2a4078bcd01cba6b54b867bdf9fdd8b"}, + {file = "tokenizers-0.21.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c212aa4e45ec0bb5274b16b6f31dd3f1c41944025c2358faaa5782c754e84c24"}, + {file = "tokenizers-0.21.4-cp39-abi3-win32.whl", hash = "sha256:6c42a930bc5f4c47f4ea775c91de47d27910881902b0f20e4990ebe045a415d0"}, + {file = "tokenizers-0.21.4-cp39-abi3-win_amd64.whl", hash = "sha256:475d807a5c3eb72c59ad9b5fcdb254f6e17f53dfcbb9903233b0dfa9c943b597"}, + {file = "tokenizers-0.21.4.tar.gz", hash = "sha256:fa23f85fbc9a02ec5c6978da172cdcbac23498c3ca9f3645c5c68740ac007880"}, +] + +[package.dependencies] +huggingface-hub = ">=0.16.4,<1.0" + +[package.extras] +dev = ["tokenizers[testing]"] +docs = ["setuptools-rust", "sphinx", "sphinx-rtd-theme"] +testing = ["black (==22.3)", "datasets", "numpy", "pytest", "requests", "ruff"] + [[package]] name = "tomli" version = "2.2.1" description = "A lil' TOML parser" optional = false python-versions = ">=3.8" +groups = ["main", "dev"] +markers = "python_version < \"3.11\"" files = [ {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, @@ -3688,13 +6213,14 @@ files = [ [[package]] name = "tomlkit" -version = "0.13.2" +version = "0.13.3" description = "Style preserving TOML library" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ - {file = "tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde"}, - {file = "tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79"}, + {file = "tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0"}, + {file = "tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1"}, ] [[package]] @@ -3703,6 +6229,7 @@ version = "4.67.1" description = "Fast, Extensible Progress Meter" optional = false python-versions = ">=3.7" +groups = ["main"] files = [ {file = "tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2"}, {file = "tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2"}, @@ -3718,37 +6245,89 @@ notebook = ["ipywidgets (>=6)"] slack = ["slack-sdk"] telegram = ["requests"] +[[package]] +name = "trove-classifiers" +version = "2025.5.9.12" +description = "Canonical source for classifiers on PyPI (pypi.org)." +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "trove_classifiers-2025.5.9.12-py3-none-any.whl", hash = "sha256:e381c05537adac78881c8fa345fd0e9970159f4e4a04fcc42cfd3129cca640ce"}, + {file = "trove_classifiers-2025.5.9.12.tar.gz", hash = "sha256:7ca7c8a7a76e2cd314468c677c69d12cc2357711fcab4a60f87994c1589e5cb5"}, +] + +[[package]] +name = "tweepy" +version = "4.16.0" +description = "Library for accessing the X API (Twitter)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "tweepy-4.16.0-py3-none-any.whl", hash = "sha256:48d1a1eb311d2c4b8990abcfa6f9fa2b2ad61be05c723b1a9b4f242656badae2"}, + {file = "tweepy-4.16.0.tar.gz", hash = "sha256:1d95cbdc50bf6353a387f881f2584eaf60d14e00dbbdd8872a73de79c66878e3"}, +] + +[package.dependencies] +oauthlib = ">=3.2.0,<4" +requests = ">=2.27.0,<3" +requests-oauthlib = ">=1.2.0,<3" + +[package.extras] +async = ["aiohttp (>=3.7.3,<4)", "async-lru (>=1.0.3,<3)"] +dev = ["coverage (>=4.4.2)", "coveralls (>=2.1.0)", "tox (>=3.21.0)"] +test = ["urllib3 (<2)", "vcrpy (>=1.10.3)"] + [[package]] name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" +version = "4.14.1" +description = "Backported and Experimental Type Hints for Python 3.9+" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, + {file = "typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76"}, + {file = "typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36"}, ] +[[package]] +name = "typing-inspection" +version = "0.4.1" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51"}, + {file = "typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + [[package]] name = "tzdata" -version = "2024.2" +version = "2025.2" description = "Provider of IANA time zone data" optional = false python-versions = ">=2" +groups = ["main", "dev"] files = [ - {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"}, - {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"}, + {file = "tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8"}, + {file = "tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9"}, ] [[package]] name = "tzlocal" -version = "5.2" +version = "5.3.1" description = "tzinfo object for the local timezone" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"}, - {file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"}, + {file = "tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d"}, + {file = "tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd"}, ] [package.dependencies] @@ -3763,6 +6342,7 @@ version = "0.18.0" description = "A python module that will check for package updates." optional = false python-versions = "*" +groups = ["main"] files = [ {file = "update_checker-0.18.0-py3-none-any.whl", hash = "sha256:cbba64760a36fe2640d80d85306e8fe82b6816659190993b7bdabadee4d4bbfd"}, {file = "update_checker-0.18.0.tar.gz", hash = "sha256:6a2d45bb4ac585884a6b03f9eade9161cedd9e8111545141e9aa9058932acb13"}, @@ -3778,41 +6358,44 @@ test = ["pytest (>=2.7.3)"] [[package]] name = "uritemplate" -version = "4.1.1" +version = "4.2.0" description = "Implementation of RFC 6570 URI Templates" optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, - {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, + {file = "uritemplate-4.2.0-py3-none-any.whl", hash = "sha256:962201ba1c4edcab02e60f9a0d3821e82dfc5d2d6662a21abd533879bdb8a686"}, + {file = "uritemplate-4.2.0.tar.gz", hash = "sha256:480c2ed180878955863323eea31b0ede668795de182617fef9c6ca09e6ec9d0e"}, ] [[package]] name = "urllib3" -version = "2.2.3" +version = "2.5.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" +groups = ["main", "dev"] files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, + {file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"}, + {file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] [[package]] name = "uvicorn" -version = "0.34.0" +version = "0.35.0" description = "The lightning-fast ASGI server." optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4"}, - {file = "uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9"}, + {file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"}, + {file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"}, ] [package.dependencies] @@ -3823,12 +6406,12 @@ httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standar python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""} typing-extensions = {version = ">=4.0", markers = "python_version < \"3.11\""} -uvloop = {version = ">=0.14.0,<0.15.0 || >0.15.0,<0.15.1 || >0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""} +uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""} watchfiles = {version = ">=0.13", optional = true, markers = "extra == \"standard\""} websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""} [package.extras] -standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.14.0,!=0.15.0,!=0.15.1)", "watchfiles (>=0.13)", "websockets (>=10.4)"] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] [[package]] name = "uvloop" @@ -3836,6 +6419,8 @@ version = "0.21.0" description = "Fast implementation of asyncio event loop on top of libuv" optional = false python-versions = ">=3.8.0" +groups = ["main"] +markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"" files = [ {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ec7e6b09a6fdded42403182ab6b832b71f4edaf7f37a9a0e371a01db5f0cb45f"}, {file = "uvloop-0.21.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:196274f2adb9689a289ad7d65700d37df0c0930fd8e4e743fa4834e850d7719d"}, @@ -3881,12 +6466,34 @@ dev = ["Cython (>=3.0,<4.0)", "setuptools (>=60)"] docs = ["Sphinx (>=4.1.2,<4.2.0)", "sphinx-rtd-theme (>=0.5.2,<0.6.0)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"] test = ["aiohttp (>=3.10.5)", "flake8 (>=5.0,<6.0)", "mypy (>=0.800)", "psutil", "pyOpenSSL (>=23.0.0,<23.1.0)", "pycodestyle (>=2.9.0,<2.10.0)"] +[[package]] +name = "virtualenv" +version = "20.31.2" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11"}, + {file = "virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + [[package]] name = "watchdog" version = "6.0.0" description = "Filesystem events monitoring" optional = false python-versions = ">=3.9" +groups = ["dev"] files = [ {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, @@ -3925,93 +6532,142 @@ watchmedo = ["PyYAML (>=3.10)"] [[package]] name = "watchfiles" -version = "1.0.3" +version = "1.1.0" description = "Simple, modern and high performance file watching and code reload in python." optional = false python-versions = ">=3.9" -files = [ - {file = "watchfiles-1.0.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:1da46bb1eefb5a37a8fb6fd52ad5d14822d67c498d99bda8754222396164ae42"}, - {file = "watchfiles-1.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2b961b86cd3973f5822826017cad7f5a75795168cb645c3a6b30c349094e02e3"}, - {file = "watchfiles-1.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34e87c7b3464d02af87f1059fedda5484e43b153ef519e4085fe1a03dd94801e"}, - {file = "watchfiles-1.0.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9dd2b89a16cf7ab9c1170b5863e68de6bf83db51544875b25a5f05a7269e678"}, - {file = "watchfiles-1.0.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2b4691234d31686dca133c920f94e478b548a8e7c750f28dbbc2e4333e0d3da9"}, - {file = "watchfiles-1.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:90b0fe1fcea9bd6e3084b44875e179b4adcc4057a3b81402658d0eb58c98edf8"}, - {file = "watchfiles-1.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b90651b4cf9e158d01faa0833b073e2e37719264bcee3eac49fc3c74e7d304b"}, - {file = "watchfiles-1.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2e9fe695ff151b42ab06501820f40d01310fbd58ba24da8923ace79cf6d702d"}, - {file = "watchfiles-1.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62691f1c0894b001c7cde1195c03b7801aaa794a837bd6eef24da87d1542838d"}, - {file = "watchfiles-1.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:275c1b0e942d335fccb6014d79267d1b9fa45b5ac0639c297f1e856f2f532552"}, - {file = "watchfiles-1.0.3-cp310-cp310-win32.whl", hash = "sha256:06ce08549e49ba69ccc36fc5659a3d0ff4e3a07d542b895b8a9013fcab46c2dc"}, - {file = "watchfiles-1.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:f280b02827adc9d87f764972fbeb701cf5611f80b619c20568e1982a277d6146"}, - {file = "watchfiles-1.0.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ffe709b1d0bc2e9921257569675674cafb3a5f8af689ab9f3f2b3f88775b960f"}, - {file = "watchfiles-1.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:418c5ce332f74939ff60691e5293e27c206c8164ce2b8ce0d9abf013003fb7fe"}, - {file = "watchfiles-1.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f492d2907263d6d0d52f897a68647195bc093dafed14508a8d6817973586b6b"}, - {file = "watchfiles-1.0.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:48c9f3bc90c556a854f4cab6a79c16974099ccfa3e3e150673d82d47a4bc92c9"}, - {file = "watchfiles-1.0.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:75d3bcfa90454dba8df12adc86b13b6d85fda97d90e708efc036c2760cc6ba44"}, - {file = "watchfiles-1.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5691340f259b8f76b45fb31b98e594d46c36d1dc8285efa7975f7f50230c9093"}, - {file = "watchfiles-1.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1e263cc718545b7f897baeac1f00299ab6fabe3e18caaacacb0edf6d5f35513c"}, - {file = "watchfiles-1.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c6cf7709ed3e55704cc06f6e835bf43c03bc8e3cb8ff946bf69a2e0a78d9d77"}, - {file = "watchfiles-1.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:703aa5e50e465be901e0e0f9d5739add15e696d8c26c53bc6fc00eb65d7b9469"}, - {file = "watchfiles-1.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bfcae6aecd9e0cb425f5145afee871465b98b75862e038d42fe91fd753ddd780"}, - {file = "watchfiles-1.0.3-cp311-cp311-win32.whl", hash = "sha256:6a76494d2c5311584f22416c5a87c1e2cb954ff9b5f0988027bc4ef2a8a67181"}, - {file = "watchfiles-1.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:cf745cbfad6389c0e331786e5fe9ae3f06e9d9c2ce2432378e1267954793975c"}, - {file = "watchfiles-1.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:2dcc3f60c445f8ce14156854a072ceb36b83807ed803d37fdea2a50e898635d6"}, - {file = "watchfiles-1.0.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:93436ed550e429da007fbafb723e0769f25bae178fbb287a94cb4ccdf42d3af3"}, - {file = "watchfiles-1.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c18f3502ad0737813c7dad70e3e1cc966cc147fbaeef47a09463bbffe70b0a00"}, - {file = "watchfiles-1.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6a5bc3ca468bb58a2ef50441f953e1f77b9a61bd1b8c347c8223403dc9b4ac9a"}, - {file = "watchfiles-1.0.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d1ec043f02ca04bf21b1b32cab155ce90c651aaf5540db8eb8ad7f7e645cba8"}, - {file = "watchfiles-1.0.3-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f58d3bfafecf3d81c15d99fc0ecf4319e80ac712c77cf0ce2661c8cf8bf84066"}, - {file = "watchfiles-1.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1df924ba82ae9e77340101c28d56cbaff2c991bd6fe8444a545d24075abb0a87"}, - {file = "watchfiles-1.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:632a52dcaee44792d0965c17bdfe5dc0edad5b86d6a29e53d6ad4bf92dc0ff49"}, - {file = "watchfiles-1.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bf4b459d94a0387617a1b499f314aa04d8a64b7a0747d15d425b8c8b151da0"}, - {file = "watchfiles-1.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ca94c85911601b097d53caeeec30201736ad69a93f30d15672b967558df02885"}, - {file = "watchfiles-1.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:65ab1fb635476f6170b07e8e21db0424de94877e4b76b7feabfe11f9a5fc12b5"}, - {file = "watchfiles-1.0.3-cp312-cp312-win32.whl", hash = "sha256:49bc1bc26abf4f32e132652f4b3bfeec77d8f8f62f57652703ef127e85a3e38d"}, - {file = "watchfiles-1.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:48681c86f2cb08348631fed788a116c89c787fdf1e6381c5febafd782f6c3b44"}, - {file = "watchfiles-1.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:9e080cf917b35b20c889225a13f290f2716748362f6071b859b60b8847a6aa43"}, - {file = "watchfiles-1.0.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e153a690b7255c5ced17895394b4f109d5dcc2a4f35cb809374da50f0e5c456a"}, - {file = "watchfiles-1.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ac1be85fe43b4bf9a251978ce5c3bb30e1ada9784290441f5423a28633a958a7"}, - {file = "watchfiles-1.0.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2ec98e31e1844eac860e70d9247db9d75440fc8f5f679c37d01914568d18721"}, - {file = "watchfiles-1.0.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0179252846be03fa97d4d5f8233d1c620ef004855f0717712ae1c558f1974a16"}, - {file = "watchfiles-1.0.3-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:995c374e86fa82126c03c5b4630c4e312327ecfe27761accb25b5e1d7ab50ec8"}, - {file = "watchfiles-1.0.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29b9cb35b7f290db1c31fb2fdf8fc6d3730cfa4bca4b49761083307f441cac5a"}, - {file = "watchfiles-1.0.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f8dc09ae69af50bead60783180f656ad96bd33ffbf6e7a6fce900f6d53b08f1"}, - {file = "watchfiles-1.0.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:489b80812f52a8d8c7b0d10f0d956db0efed25df2821c7a934f6143f76938bd6"}, - {file = "watchfiles-1.0.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:228e2247de583475d4cebf6b9af5dc9918abb99d1ef5ee737155bb39fb33f3c0"}, - {file = "watchfiles-1.0.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1550be1a5cb3be08a3fb84636eaafa9b7119b70c71b0bed48726fd1d5aa9b868"}, - {file = "watchfiles-1.0.3-cp313-cp313-win32.whl", hash = "sha256:16db2d7e12f94818cbf16d4c8938e4d8aaecee23826344addfaaa671a1527b07"}, - {file = "watchfiles-1.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:160eff7d1267d7b025e983ca8460e8cc67b328284967cbe29c05f3c3163711a3"}, - {file = "watchfiles-1.0.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:c05b021f7b5aa333124f2a64d56e4cb9963b6efdf44e8d819152237bbd93ba15"}, - {file = "watchfiles-1.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:310505ad305e30cb6c5f55945858cdbe0eb297fc57378f29bacceb534ac34199"}, - {file = "watchfiles-1.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ddff3f8b9fa24a60527c137c852d0d9a7da2a02cf2151650029fdc97c852c974"}, - {file = "watchfiles-1.0.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46e86ed457c3486080a72bc837300dd200e18d08183f12b6ca63475ab64ed651"}, - {file = "watchfiles-1.0.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f79fe7993e230a12172ce7d7c7db061f046f672f2b946431c81aff8f60b2758b"}, - {file = "watchfiles-1.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ea2b51c5f38bad812da2ec0cd7eec09d25f521a8b6b6843cbccedd9a1d8a5c15"}, - {file = "watchfiles-1.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fe4e740ea94978b2b2ab308cbf9270a246bcbb44401f77cc8740348cbaeac3d"}, - {file = "watchfiles-1.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9af037d3df7188ae21dc1c7624501f2f90d81be6550904e07869d8d0e6766655"}, - {file = "watchfiles-1.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:52bb50a4c4ca2a689fdba84ba8ecc6a4e6210f03b6af93181bb61c4ec3abaf86"}, - {file = "watchfiles-1.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c14a07bdb475eb696f85c715dbd0f037918ccbb5248290448488a0b4ef201aad"}, - {file = "watchfiles-1.0.3-cp39-cp39-win32.whl", hash = "sha256:be37f9b1f8934cd9e7eccfcb5612af9fb728fecbe16248b082b709a9d1b348bf"}, - {file = "watchfiles-1.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:ef9ec8068cf23458dbf36a08e0c16f0a2df04b42a8827619646637be1769300a"}, - {file = "watchfiles-1.0.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:84fac88278f42d61c519a6c75fb5296fd56710b05bbdcc74bdf85db409a03780"}, - {file = "watchfiles-1.0.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:c68be72b1666d93b266714f2d4092d78dc53bd11cf91ed5a3c16527587a52e29"}, - {file = "watchfiles-1.0.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:889a37e2acf43c377b5124166bece139b4c731b61492ab22e64d371cce0e6e80"}, - {file = "watchfiles-1.0.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ca05cacf2e5c4a97d02a2878a24020daca21dbb8823b023b978210a75c79098"}, - {file = "watchfiles-1.0.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:8af4b582d5fc1b8465d1d2483e5e7b880cc1a4e99f6ff65c23d64d070867ac58"}, - {file = "watchfiles-1.0.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:127de3883bdb29dbd3b21f63126bb8fa6e773b74eaef46521025a9ce390e1073"}, - {file = "watchfiles-1.0.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:713f67132346bdcb4c12df185c30cf04bdf4bf6ea3acbc3ace0912cab6b7cb8c"}, - {file = "watchfiles-1.0.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abd85de513eb83f5ec153a802348e7a5baa4588b818043848247e3e8986094e8"}, - {file = "watchfiles-1.0.3.tar.gz", hash = "sha256:f3ff7da165c99a5412fe5dd2304dd2dbaaaa5da718aad942dcb3a178eaa70c56"}, +groups = ["main"] +files = [ + {file = "watchfiles-1.1.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:27f30e14aa1c1e91cb653f03a63445739919aef84c8d2517997a83155e7a2fcc"}, + {file = "watchfiles-1.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:3366f56c272232860ab45c77c3ca7b74ee819c8e1f6f35a7125556b198bbc6df"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8412eacef34cae2836d891836a7fff7b754d6bcac61f6c12ba5ca9bc7e427b68"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:df670918eb7dd719642e05979fc84704af913d563fd17ed636f7c4783003fdcc"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d7642b9bc4827b5518ebdb3b82698ada8c14c7661ddec5fe719f3e56ccd13c97"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:199207b2d3eeaeb80ef4411875a6243d9ad8bc35b07fc42daa6b801cc39cc41c"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a479466da6db5c1e8754caee6c262cd373e6e6c363172d74394f4bff3d84d7b5"}, + {file = "watchfiles-1.1.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:935f9edd022ec13e447e5723a7d14456c8af254544cefbc533f6dd276c9aa0d9"}, + {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:8076a5769d6bdf5f673a19d51da05fc79e2bbf25e9fe755c47595785c06a8c72"}, + {file = "watchfiles-1.1.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:86b1e28d4c37e89220e924305cd9f82866bb0ace666943a6e4196c5df4d58dcc"}, + {file = "watchfiles-1.1.0-cp310-cp310-win32.whl", hash = "sha256:d1caf40c1c657b27858f9774d5c0e232089bca9cb8ee17ce7478c6e9264d2587"}, + {file = "watchfiles-1.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:a89c75a5b9bc329131115a409d0acc16e8da8dfd5867ba59f1dd66ae7ea8fa82"}, + {file = "watchfiles-1.1.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c9649dfc57cc1f9835551deb17689e8d44666315f2e82d337b9f07bd76ae3aa2"}, + {file = "watchfiles-1.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:406520216186b99374cdb58bc48e34bb74535adec160c8459894884c983a149c"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb45350fd1dc75cd68d3d72c47f5b513cb0578da716df5fba02fff31c69d5f2d"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:11ee4444250fcbeb47459a877e5e80ed994ce8e8d20283857fc128be1715dac7"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bda8136e6a80bdea23e5e74e09df0362744d24ffb8cd59c4a95a6ce3d142f79c"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b915daeb2d8c1f5cee4b970f2e2c988ce6514aace3c9296e58dd64dc9aa5d575"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed8fc66786de8d0376f9f913c09e963c66e90ced9aa11997f93bdb30f7c872a8"}, + {file = "watchfiles-1.1.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe4371595edf78c41ef8ac8df20df3943e13defd0efcb732b2e393b5a8a7a71f"}, + {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b7c5f6fe273291f4d414d55b2c80d33c457b8a42677ad14b4b47ff025d0893e4"}, + {file = "watchfiles-1.1.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7738027989881e70e3723c75921f1efa45225084228788fc59ea8c6d732eb30d"}, + {file = "watchfiles-1.1.0-cp311-cp311-win32.whl", hash = "sha256:622d6b2c06be19f6e89b1d951485a232e3b59618def88dbeda575ed8f0d8dbf2"}, + {file = "watchfiles-1.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:48aa25e5992b61debc908a61ab4d3f216b64f44fdaa71eb082d8b2de846b7d12"}, + {file = "watchfiles-1.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:00645eb79a3faa70d9cb15c8d4187bb72970b2470e938670240c7998dad9f13a"}, + {file = "watchfiles-1.1.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9dc001c3e10de4725c749d4c2f2bdc6ae24de5a88a339c4bce32300a31ede179"}, + {file = "watchfiles-1.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d9ba68ec283153dead62cbe81872d28e053745f12335d037de9cbd14bd1877f5"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130fc497b8ee68dce163e4254d9b0356411d1490e868bd8790028bc46c5cc297"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:50a51a90610d0845a5931a780d8e51d7bd7f309ebc25132ba975aca016b576a0"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc44678a72ac0910bac46fa6a0de6af9ba1355669b3dfaf1ce5f05ca7a74364e"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a543492513a93b001975ae283a51f4b67973662a375a403ae82f420d2c7205ee"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ac164e20d17cc285f2b94dc31c384bc3aa3dd5e7490473b3db043dd70fbccfd"}, + {file = "watchfiles-1.1.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7590d5a455321e53857892ab8879dce62d1f4b04748769f5adf2e707afb9d4f"}, + {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:37d3d3f7defb13f62ece99e9be912afe9dd8a0077b7c45ee5a57c74811d581a4"}, + {file = "watchfiles-1.1.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:7080c4bb3efd70a07b1cc2df99a7aa51d98685be56be6038c3169199d0a1c69f"}, + {file = "watchfiles-1.1.0-cp312-cp312-win32.whl", hash = "sha256:cbcf8630ef4afb05dc30107bfa17f16c0896bb30ee48fc24bf64c1f970f3b1fd"}, + {file = "watchfiles-1.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:cbd949bdd87567b0ad183d7676feb98136cde5bb9025403794a4c0db28ed3a47"}, + {file = "watchfiles-1.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0a7d40b77f07be87c6faa93d0951a0fcd8cbca1ddff60a1b65d741bac6f3a9f6"}, + {file = "watchfiles-1.1.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5007f860c7f1f8df471e4e04aaa8c43673429047d63205d1630880f7637bca30"}, + {file = "watchfiles-1.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:20ecc8abbd957046f1fe9562757903f5eaf57c3bce70929fda6c7711bb58074a"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2f0498b7d2a3c072766dba3274fe22a183dbea1f99d188f1c6c72209a1063dc"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:239736577e848678e13b201bba14e89718f5c2133dfd6b1f7846fa1b58a8532b"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eff4b8d89f444f7e49136dc695599a591ff769300734446c0a86cba2eb2f9895"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12b0a02a91762c08f7264e2e79542f76870c3040bbc847fb67410ab81474932a"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29e7bc2eee15cbb339c68445959108803dc14ee0c7b4eea556400131a8de462b"}, + {file = "watchfiles-1.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d9481174d3ed982e269c090f780122fb59cee6c3796f74efe74e70f7780ed94c"}, + {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:80f811146831c8c86ab17b640801c25dc0a88c630e855e2bef3568f30434d52b"}, + {file = "watchfiles-1.1.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:60022527e71d1d1fda67a33150ee42869042bce3d0fcc9cc49be009a9cded3fb"}, + {file = "watchfiles-1.1.0-cp313-cp313-win32.whl", hash = "sha256:32d6d4e583593cb8576e129879ea0991660b935177c0f93c6681359b3654bfa9"}, + {file = "watchfiles-1.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:f21af781a4a6fbad54f03c598ab620e3a77032c5878f3d780448421a6e1818c7"}, + {file = "watchfiles-1.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:5366164391873ed76bfdf618818c82084c9db7fac82b64a20c44d335eec9ced5"}, + {file = "watchfiles-1.1.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:17ab167cca6339c2b830b744eaf10803d2a5b6683be4d79d8475d88b4a8a4be1"}, + {file = "watchfiles-1.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:328dbc9bff7205c215a7807da7c18dce37da7da718e798356212d22696404339"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7208ab6e009c627b7557ce55c465c98967e8caa8b11833531fdf95799372633"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8f6f72974a19efead54195bc9bed4d850fc047bb7aa971268fd9a8387c89011"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d181ef50923c29cf0450c3cd47e2f0557b62218c50b2ab8ce2ecaa02bd97e670"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adb4167043d3a78280d5d05ce0ba22055c266cf8655ce942f2fb881262ff3cdf"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5701dc474b041e2934a26d31d39f90fac8a3dee2322b39f7729867f932b1d4"}, + {file = "watchfiles-1.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b067915e3c3936966a8607f6fe5487df0c9c4afb85226613b520890049deea20"}, + {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:9c733cda03b6d636b4219625a4acb5c6ffb10803338e437fb614fef9516825ef"}, + {file = "watchfiles-1.1.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:cc08ef8b90d78bfac66f0def80240b0197008e4852c9f285907377b2947ffdcb"}, + {file = "watchfiles-1.1.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:9974d2f7dc561cce3bb88dfa8eb309dab64c729de85fba32e98d75cf24b66297"}, + {file = "watchfiles-1.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c68e9f1fcb4d43798ad8814c4c1b61547b014b667216cb754e606bfade587018"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95ab1594377effac17110e1352989bdd7bdfca9ff0e5eeccd8c69c5389b826d0"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fba9b62da882c1be1280a7584ec4515d0a6006a94d6e5819730ec2eab60ffe12"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3434e401f3ce0ed6b42569128b3d1e3af773d7ec18751b918b89cd49c14eaafb"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fa257a4d0d21fcbca5b5fcba9dca5a78011cb93c0323fb8855c6d2dfbc76eb77"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd1b3879a578a8ec2076c7961076df540b9af317123f84569f5a9ddee64ce92"}, + {file = "watchfiles-1.1.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62cc7a30eeb0e20ecc5f4bd113cd69dcdb745a07c68c0370cea919f373f65d9e"}, + {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:891c69e027748b4a73847335d208e374ce54ca3c335907d381fde4e41661b13b"}, + {file = "watchfiles-1.1.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:12fe8eaffaf0faa7906895b4f8bb88264035b3f0243275e0bf24af0436b27259"}, + {file = "watchfiles-1.1.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bfe3c517c283e484843cb2e357dd57ba009cff351edf45fb455b5fbd1f45b15f"}, + {file = "watchfiles-1.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a9ccbf1f129480ed3044f540c0fdbc4ee556f7175e5ab40fe077ff6baf286d4e"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba0e3255b0396cac3cc7bbace76404dd72b5438bf0d8e7cefa2f79a7f3649caa"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4281cd9fce9fc0a9dbf0fc1217f39bf9cf2b4d315d9626ef1d4e87b84699e7e8"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d2404af8db1329f9a3c9b79ff63e0ae7131986446901582067d9304ae8aaf7f"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e78b6ed8165996013165eeabd875c5dfc19d41b54f94b40e9fff0eb3193e5e8e"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:249590eb75ccc117f488e2fabd1bfa33c580e24b96f00658ad88e38844a040bb"}, + {file = "watchfiles-1.1.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d05686b5487cfa2e2c28ff1aa370ea3e6c5accfe6435944ddea1e10d93872147"}, + {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d0e10e6f8f6dc5762adee7dece33b722282e1f59aa6a55da5d493a97282fedd8"}, + {file = "watchfiles-1.1.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:af06c863f152005c7592df1d6a7009c836a247c9d8adb78fef8575a5a98699db"}, + {file = "watchfiles-1.1.0-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:865c8e95713744cf5ae261f3067861e9da5f1370ba91fc536431e29b418676fa"}, + {file = "watchfiles-1.1.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:42f92befc848bb7a19658f21f3e7bae80d7d005d13891c62c2cd4d4d0abb3433"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa0cc8365ab29487eb4f9979fd41b22549853389e22d5de3f134a6796e1b05a4"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:90ebb429e933645f3da534c89b29b665e285048973b4d2b6946526888c3eb2c7"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c588c45da9b08ab3da81d08d7987dae6d2a3badd63acdb3e206a42dbfa7cb76f"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c55b0f9f68590115c25272b06e63f0824f03d4fc7d6deed43d8ad5660cabdbf"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd17a1e489f02ce9117b0de3c0b1fab1c3e2eedc82311b299ee6b6faf6c23a29"}, + {file = "watchfiles-1.1.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:da71945c9ace018d8634822f16cbc2a78323ef6c876b1d34bbf5d5222fd6a72e"}, + {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:51556d5004887045dba3acdd1fdf61dddea2be0a7e18048b5e853dcd37149b86"}, + {file = "watchfiles-1.1.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04e4ed5d1cd3eae68c89bcc1a485a109f39f2fd8de05f705e98af6b5f1861f1f"}, + {file = "watchfiles-1.1.0-cp39-cp39-win32.whl", hash = "sha256:c600e85f2ffd9f1035222b1a312aff85fd11ea39baff1d705b9b047aad2ce267"}, + {file = "watchfiles-1.1.0-cp39-cp39-win_amd64.whl", hash = "sha256:3aba215958d88182e8d2acba0fdaf687745180974946609119953c0e112397dc"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a6fd40bbb50d24976eb275ccb55cd1951dfb63dbc27cae3066a6ca5f4beabd5"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9f811079d2f9795b5d48b55a37aa7773680a5659afe34b54cc1d86590a51507d"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2726d7bfd9f76158c84c10a409b77a320426540df8c35be172444394b17f7ea"}, + {file = "watchfiles-1.1.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df32d59cb9780f66d165a9a7a26f19df2c7d24e3bd58713108b41d0ff4f929c6"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0ece16b563b17ab26eaa2d52230c9a7ae46cf01759621f4fbbca280e438267b3"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:51b81e55d40c4b4aa8658427a3ee7ea847c591ae9e8b81ef94a90b668999353c"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2bcdc54ea267fe72bfc7d83c041e4eb58d7d8dc6f578dfddb52f037ce62f432"}, + {file = "watchfiles-1.1.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:923fec6e5461c42bd7e3fd5ec37492c6f3468be0499bc0707b4bbbc16ac21792"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7b3443f4ec3ba5aa00b0e9fa90cf31d98321cbff8b925a7c7b84161619870bc9"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:7049e52167fc75fc3cc418fc13d39a8e520cbb60ca08b47f6cedb85e181d2f2a"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54062ef956807ba806559b3c3d52105ae1827a0d4ab47b621b31132b6b7e2866"}, + {file = "watchfiles-1.1.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a7bd57a1bb02f9d5c398c0c1675384e7ab1dd39da0ca50b7f09af45fa435277"}, + {file = "watchfiles-1.1.0.tar.gz", hash = "sha256:693ed7ec72cbfcee399e92c895362b6e66d63dac6b91e2c11ae03d10d503e575"}, ] [package.dependencies] anyio = ">=3.0.0" +[[package]] +name = "webencodings" +version = "0.5.1" +description = "Character encoding aliases for legacy web content" +optional = false +python-versions = "*" +groups = ["main"] +files = [ + {file = "webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78"}, + {file = "webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"}, +] + [[package]] name = "websocket-client" version = "1.8.0" description = "WebSocket client for Python with low level API options" optional = false python-versions = ">=3.8" +groups = ["main"] files = [ {file = "websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526"}, {file = "websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da"}, @@ -4024,304 +6680,454 @@ test = ["websockets"] [[package]] name = "websockets" -version = "13.1" +version = "15.0.1" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" optional = false -python-versions = ">=3.8" -files = [ - {file = "websockets-13.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:f48c749857f8fb598fb890a75f540e3221d0976ed0bf879cf3c7eef34151acee"}, - {file = "websockets-13.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c7e72ce6bda6fb9409cc1e8164dd41d7c91466fb599eb047cfda72fe758a34a7"}, - {file = "websockets-13.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f779498eeec470295a2b1a5d97aa1bc9814ecd25e1eb637bd9d1c73a327387f6"}, - {file = "websockets-13.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4676df3fe46956fbb0437d8800cd5f2b6d41143b6e7e842e60554398432cf29b"}, - {file = "websockets-13.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7affedeb43a70351bb811dadf49493c9cfd1ed94c9c70095fd177e9cc1541fa"}, - {file = "websockets-13.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1971e62d2caa443e57588e1d82d15f663b29ff9dfe7446d9964a4b6f12c1e700"}, - {file = "websockets-13.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5f2e75431f8dc4a47f31565a6e1355fb4f2ecaa99d6b89737527ea917066e26c"}, - {file = "websockets-13.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:58cf7e75dbf7e566088b07e36ea2e3e2bd5676e22216e4cad108d4df4a7402a0"}, - {file = "websockets-13.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c90d6dec6be2c7d03378a574de87af9b1efea77d0c52a8301dd831ece938452f"}, - {file = "websockets-13.1-cp310-cp310-win32.whl", hash = "sha256:730f42125ccb14602f455155084f978bd9e8e57e89b569b4d7f0f0c17a448ffe"}, - {file = "websockets-13.1-cp310-cp310-win_amd64.whl", hash = "sha256:5993260f483d05a9737073be197371940c01b257cc45ae3f1d5d7adb371b266a"}, - {file = "websockets-13.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:61fc0dfcda609cda0fc9fe7977694c0c59cf9d749fbb17f4e9483929e3c48a19"}, - {file = "websockets-13.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ceec59f59d092c5007e815def4ebb80c2de330e9588e101cf8bd94c143ec78a5"}, - {file = "websockets-13.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1dca61c6db1166c48b95198c0b7d9c990b30c756fc2923cc66f68d17dc558fd"}, - {file = "websockets-13.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:308e20f22c2c77f3f39caca508e765f8725020b84aa963474e18c59accbf4c02"}, - {file = "websockets-13.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:62d516c325e6540e8a57b94abefc3459d7dab8ce52ac75c96cad5549e187e3a7"}, - {file = "websockets-13.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87c6e35319b46b99e168eb98472d6c7d8634ee37750d7693656dc766395df096"}, - {file = "websockets-13.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5f9fee94ebafbc3117c30be1844ed01a3b177bb6e39088bc6b2fa1dc15572084"}, - {file = "websockets-13.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7c1e90228c2f5cdde263253fa5db63e6653f1c00e7ec64108065a0b9713fa1b3"}, - {file = "websockets-13.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6548f29b0e401eea2b967b2fdc1c7c7b5ebb3eeb470ed23a54cd45ef078a0db9"}, - {file = "websockets-13.1-cp311-cp311-win32.whl", hash = "sha256:c11d4d16e133f6df8916cc5b7e3e96ee4c44c936717d684a94f48f82edb7c92f"}, - {file = "websockets-13.1-cp311-cp311-win_amd64.whl", hash = "sha256:d04f13a1d75cb2b8382bdc16ae6fa58c97337253826dfe136195b7f89f661557"}, - {file = "websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc"}, - {file = "websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49"}, - {file = "websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6"}, - {file = "websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14"}, - {file = "websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf"}, - {file = "websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c"}, - {file = "websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3"}, - {file = "websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6"}, - {file = "websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708"}, - {file = "websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418"}, - {file = "websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a"}, - {file = "websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f"}, - {file = "websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5"}, - {file = "websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135"}, - {file = "websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2"}, - {file = "websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6"}, - {file = "websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d"}, - {file = "websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2"}, - {file = "websockets-13.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:c7934fd0e920e70468e676fe7f1b7261c1efa0d6c037c6722278ca0228ad9d0d"}, - {file = "websockets-13.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:149e622dc48c10ccc3d2760e5f36753db9cacf3ad7bc7bbbfd7d9c819e286f23"}, - {file = "websockets-13.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a569eb1b05d72f9bce2ebd28a1ce2054311b66677fcd46cf36204ad23acead8c"}, - {file = "websockets-13.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:95df24ca1e1bd93bbca51d94dd049a984609687cb2fb08a7f2c56ac84e9816ea"}, - {file = "websockets-13.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d8dbb1bf0c0a4ae8b40bdc9be7f644e2f3fb4e8a9aca7145bfa510d4a374eeb7"}, - {file = "websockets-13.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:035233b7531fb92a76beefcbf479504db8c72eb3bff41da55aecce3a0f729e54"}, - {file = "websockets-13.1-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:e4450fc83a3df53dec45922b576e91e94f5578d06436871dce3a6be38e40f5db"}, - {file = "websockets-13.1-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:463e1c6ec853202dd3657f156123d6b4dad0c546ea2e2e38be2b3f7c5b8e7295"}, - {file = "websockets-13.1-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:6d6855bbe70119872c05107e38fbc7f96b1d8cb047d95c2c50869a46c65a8e96"}, - {file = "websockets-13.1-cp38-cp38-win32.whl", hash = "sha256:204e5107f43095012b00f1451374693267adbb832d29966a01ecc4ce1db26faf"}, - {file = "websockets-13.1-cp38-cp38-win_amd64.whl", hash = "sha256:485307243237328c022bc908b90e4457d0daa8b5cf4b3723fd3c4a8012fce4c6"}, - {file = "websockets-13.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9b37c184f8b976f0c0a231a5f3d6efe10807d41ccbe4488df8c74174805eea7d"}, - {file = "websockets-13.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:163e7277e1a0bd9fb3c8842a71661ad19c6aa7bb3d6678dc7f89b17fbcc4aeb7"}, - {file = "websockets-13.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4b889dbd1342820cc210ba44307cf75ae5f2f96226c0038094455a96e64fb07a"}, - {file = "websockets-13.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:586a356928692c1fed0eca68b4d1c2cbbd1ca2acf2ac7e7ebd3b9052582deefa"}, - {file = "websockets-13.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7bd6abf1e070a6b72bfeb71049d6ad286852e285f146682bf30d0296f5fbadfa"}, - {file = "websockets-13.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d2aad13a200e5934f5a6767492fb07151e1de1d6079c003ab31e1823733ae79"}, - {file = "websockets-13.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:df01aea34b6e9e33572c35cd16bae5a47785e7d5c8cb2b54b2acdb9678315a17"}, - {file = "websockets-13.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:e54affdeb21026329fb0744ad187cf812f7d3c2aa702a5edb562b325191fcab6"}, - {file = "websockets-13.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:9ef8aa8bdbac47f4968a5d66462a2a0935d044bf35c0e5a8af152d58516dbeb5"}, - {file = "websockets-13.1-cp39-cp39-win32.whl", hash = "sha256:deeb929efe52bed518f6eb2ddc00cc496366a14c726005726ad62c2dd9017a3c"}, - {file = "websockets-13.1-cp39-cp39-win_amd64.whl", hash = "sha256:7c65ffa900e7cc958cd088b9a9157a8141c991f8c53d11087e6fb7277a03f81d"}, - {file = "websockets-13.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:5dd6da9bec02735931fccec99d97c29f47cc61f644264eb995ad6c0c27667238"}, - {file = "websockets-13.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:2510c09d8e8df777177ee3d40cd35450dc169a81e747455cc4197e63f7e7bfe5"}, - {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1c3cf67185543730888b20682fb186fc8d0fa6f07ccc3ef4390831ab4b388d9"}, - {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bcc03c8b72267e97b49149e4863d57c2d77f13fae12066622dc78fe322490fe6"}, - {file = "websockets-13.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:004280a140f220c812e65f36944a9ca92d766b6cc4560be652a0a3883a79ed8a"}, - {file = "websockets-13.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e2620453c075abeb0daa949a292e19f56de518988e079c36478bacf9546ced23"}, - {file = "websockets-13.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9156c45750b37337f7b0b00e6248991a047be4aa44554c9886fe6bdd605aab3b"}, - {file = "websockets-13.1-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:80c421e07973a89fbdd93e6f2003c17d20b69010458d3a8e37fb47874bd67d51"}, - {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:82d0ba76371769d6a4e56f7e83bb8e81846d17a6190971e38b5de108bde9b0d7"}, - {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e9875a0143f07d74dc5e1ded1c4581f0d9f7ab86c78994e2ed9e95050073c94d"}, - {file = "websockets-13.1-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a11e38ad8922c7961447f35c7b17bffa15de4d17c70abd07bfbe12d6faa3e027"}, - {file = "websockets-13.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:4059f790b6ae8768471cddb65d3c4fe4792b0ab48e154c9f0a04cefaabcd5978"}, - {file = "websockets-13.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:25c35bf84bf7c7369d247f0b8cfa157f989862c49104c5cf85cb5436a641d93e"}, - {file = "websockets-13.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:83f91d8a9bb404b8c2c41a707ac7f7f75b9442a0a876df295de27251a856ad09"}, - {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a43cfdcddd07f4ca2b1afb459824dd3c6d53a51410636a2c7fc97b9a8cf4842"}, - {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:48a2ef1381632a2f0cb4efeff34efa97901c9fbc118e01951ad7cfc10601a9bb"}, - {file = "websockets-13.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:459bf774c754c35dbb487360b12c5727adab887f1622b8aed5755880a21c4a20"}, - {file = "websockets-13.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:95858ca14a9f6fa8413d29e0a585b31b278388aa775b8a81fa24830123874678"}, - {file = "websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f"}, - {file = "websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878"}, -] - -[[package]] -name = "wrapt" -version = "1.17.0" -description = "Module for decorators, wrappers and monkey patching." +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205"}, + {file = "websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf"}, + {file = "websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9"}, + {file = "websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c"}, + {file = "websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256"}, + {file = "websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57"}, + {file = "websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792"}, + {file = "websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3"}, + {file = "websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf"}, + {file = "websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85"}, + {file = "websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665"}, + {file = "websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5"}, + {file = "websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4"}, + {file = "websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597"}, + {file = "websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9"}, + {file = "websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675"}, + {file = "websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f"}, + {file = "websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d"}, + {file = "websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4"}, + {file = "websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa"}, + {file = "websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5f4c04ead5aed67c8a1a20491d54cdfba5884507a48dd798ecaf13c74c4489f5"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:abdc0c6c8c648b4805c5eacd131910d2a7f6455dfd3becab248ef108e89ab16a"}, + {file = "websockets-15.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a625e06551975f4b7ea7102bc43895b90742746797e2e14b70ed61c43a90f09b"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d591f8de75824cbb7acad4e05d2d710484f15f29d4a915092675ad3456f11770"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47819cea040f31d670cc8d324bb6435c6f133b8c7a19ec3d61634e62f8d8f9eb"}, + {file = "websockets-15.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac017dd64572e5c3bd01939121e4d16cf30e5d7e110a119399cf3133b63ad054"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:4a9fac8e469d04ce6c25bb2610dc535235bd4aa14996b4e6dbebf5e007eba5ee"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:363c6f671b761efcb30608d24925a382497c12c506b51661883c3e22337265ed"}, + {file = "websockets-15.0.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2034693ad3097d5355bfdacfffcbd3ef5694f9718ab7f29c29689a9eae841880"}, + {file = "websockets-15.0.1-cp39-cp39-win32.whl", hash = "sha256:3b1ac0d3e594bf121308112697cf4b32be538fb1444468fb0a6ae4feebc83411"}, + {file = "websockets-15.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:b7643a03db5c95c799b89b31c036d5f27eeb4d259c798e878d6937d71832b1e4"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04"}, + {file = "websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7f493881579c90fc262d9cdbaa05a6b54b3811c2f300766748db79f098db9940"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:47b099e1f4fbc95b701b6e85768e1fcdaf1630f3cbe4765fa216596f12310e2e"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67f2b6de947f8c757db2db9c71527933ad0019737ec374a8a6be9a956786aaf9"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d08eb4c2b7d6c41da6ca0600c077e93f5adcfd979cd777d747e9ee624556da4b"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b826973a4a2ae47ba357e4e82fa44a463b8f168e1ca775ac64521442b19e87f"}, + {file = "websockets-15.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:21c1fa28a6a7e3cbdc171c694398b6df4744613ce9b36b1a498e816787e28123"}, + {file = "websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f"}, + {file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"}, +] + +[[package]] +name = "xattr" +version = "1.2.0" +description = "Python wrapper for extended filesystem attributes" optional = false python-versions = ">=3.8" -files = [ - {file = "wrapt-1.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2a0c23b8319848426f305f9cb0c98a6e32ee68a36264f45948ccf8e7d2b941f8"}, - {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1ca5f060e205f72bec57faae5bd817a1560fcfc4af03f414b08fa29106b7e2d"}, - {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e185ec6060e301a7e5f8461c86fb3640a7beb1a0f0208ffde7a65ec4074931df"}, - {file = "wrapt-1.17.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb90765dd91aed05b53cd7a87bd7f5c188fcd95960914bae0d32c5e7f899719d"}, - {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:879591c2b5ab0a7184258274c42a126b74a2c3d5a329df16d69f9cee07bba6ea"}, - {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fce6fee67c318fdfb7f285c29a82d84782ae2579c0e1b385b7f36c6e8074fffb"}, - {file = "wrapt-1.17.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0698d3a86f68abc894d537887b9bbf84d29bcfbc759e23f4644be27acf6da301"}, - {file = "wrapt-1.17.0-cp310-cp310-win32.whl", hash = "sha256:69d093792dc34a9c4c8a70e4973a3361c7a7578e9cd86961b2bbf38ca71e4e22"}, - {file = "wrapt-1.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:f28b29dc158ca5d6ac396c8e0a2ef45c4e97bb7e65522bfc04c989e6fe814575"}, - {file = "wrapt-1.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:74bf625b1b4caaa7bad51d9003f8b07a468a704e0644a700e936c357c17dd45a"}, - {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f2a28eb35cf99d5f5bd12f5dd44a0f41d206db226535b37b0c60e9da162c3ed"}, - {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:81b1289e99cf4bad07c23393ab447e5e96db0ab50974a280f7954b071d41b489"}, - {file = "wrapt-1.17.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9f2939cd4a2a52ca32bc0b359015718472d7f6de870760342e7ba295be9ebaf9"}, - {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6a9653131bda68a1f029c52157fd81e11f07d485df55410401f745007bd6d339"}, - {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4e4b4385363de9052dac1a67bfb535c376f3d19c238b5f36bddc95efae15e12d"}, - {file = "wrapt-1.17.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bdf62d25234290db1837875d4dceb2151e4ea7f9fff2ed41c0fde23ed542eb5b"}, - {file = "wrapt-1.17.0-cp311-cp311-win32.whl", hash = "sha256:5d8fd17635b262448ab8f99230fe4dac991af1dabdbb92f7a70a6afac8a7e346"}, - {file = "wrapt-1.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:92a3d214d5e53cb1db8b015f30d544bc9d3f7179a05feb8f16df713cecc2620a"}, - {file = "wrapt-1.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:89fc28495896097622c3fc238915c79365dd0ede02f9a82ce436b13bd0ab7569"}, - {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:875d240fdbdbe9e11f9831901fb8719da0bd4e6131f83aa9f69b96d18fae7504"}, - {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e5ed16d95fd142e9c72b6c10b06514ad30e846a0d0917ab406186541fe68b451"}, - {file = "wrapt-1.17.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:18b956061b8db634120b58f668592a772e87e2e78bc1f6a906cfcaa0cc7991c1"}, - {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:daba396199399ccabafbfc509037ac635a6bc18510ad1add8fd16d4739cdd106"}, - {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4d63f4d446e10ad19ed01188d6c1e1bb134cde8c18b0aa2acfd973d41fcc5ada"}, - {file = "wrapt-1.17.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a5e7cc39a45fc430af1aefc4d77ee6bad72c5bcdb1322cfde852c15192b8bd4"}, - {file = "wrapt-1.17.0-cp312-cp312-win32.whl", hash = "sha256:0a0a1a1ec28b641f2a3a2c35cbe86c00051c04fffcfcc577ffcdd707df3f8635"}, - {file = "wrapt-1.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:3c34f6896a01b84bab196f7119770fd8466c8ae3dfa73c59c0bb281e7b588ce7"}, - {file = "wrapt-1.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:714c12485aa52efbc0fc0ade1e9ab3a70343db82627f90f2ecbc898fdf0bb181"}, - {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da427d311782324a376cacb47c1a4adc43f99fd9d996ffc1b3e8529c4074d393"}, - {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ba1739fb38441a27a676f4de4123d3e858e494fac05868b7a281c0a383c098f4"}, - {file = "wrapt-1.17.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e711fc1acc7468463bc084d1b68561e40d1eaa135d8c509a65dd534403d83d7b"}, - {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:140ea00c87fafc42739bd74a94a5a9003f8e72c27c47cd4f61d8e05e6dec8721"}, - {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:73a96fd11d2b2e77d623a7f26e004cc31f131a365add1ce1ce9a19e55a1eef90"}, - {file = "wrapt-1.17.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0b48554952f0f387984da81ccfa73b62e52817a4386d070c75e4db7d43a28c4a"}, - {file = "wrapt-1.17.0-cp313-cp313-win32.whl", hash = "sha256:498fec8da10e3e62edd1e7368f4b24aa362ac0ad931e678332d1b209aec93045"}, - {file = "wrapt-1.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd136bb85f4568fffca995bd3c8d52080b1e5b225dbf1c2b17b66b4c5fa02838"}, - {file = "wrapt-1.17.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17fcf043d0b4724858f25b8826c36e08f9fb2e475410bece0ec44a22d533da9b"}, - {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4a557d97f12813dc5e18dad9fa765ae44ddd56a672bb5de4825527c847d6379"}, - {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0229b247b0fc7dee0d36176cbb79dbaf2a9eb7ecc50ec3121f40ef443155fb1d"}, - {file = "wrapt-1.17.0-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8425cfce27b8b20c9b89d77fb50e368d8306a90bf2b6eef2cdf5cd5083adf83f"}, - {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9c900108df470060174108012de06d45f514aa4ec21a191e7ab42988ff42a86c"}, - {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e547b447073fc0dbfcbff15154c1be8823d10dab4ad401bdb1575e3fdedff1b"}, - {file = "wrapt-1.17.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:914f66f3b6fc7b915d46c1cc424bc2441841083de01b90f9e81109c9759e43ab"}, - {file = "wrapt-1.17.0-cp313-cp313t-win32.whl", hash = "sha256:a4192b45dff127c7d69b3bdfb4d3e47b64179a0b9900b6351859f3001397dabf"}, - {file = "wrapt-1.17.0-cp313-cp313t-win_amd64.whl", hash = "sha256:4f643df3d4419ea3f856c5c3f40fec1d65ea2e89ec812c83f7767c8730f9827a"}, - {file = "wrapt-1.17.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:69c40d4655e078ede067a7095544bcec5a963566e17503e75a3a3e0fe2803b13"}, - {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2f495b6754358979379f84534f8dd7a43ff8cff2558dcdea4a148a6e713a758f"}, - {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:baa7ef4e0886a6f482e00d1d5bcd37c201b383f1d314643dfb0367169f94f04c"}, - {file = "wrapt-1.17.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8fc931382e56627ec4acb01e09ce66e5c03c384ca52606111cee50d931a342d"}, - {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:8f8909cdb9f1b237786c09a810e24ee5e15ef17019f7cecb207ce205b9b5fcce"}, - {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ad47b095f0bdc5585bced35bd088cbfe4177236c7df9984b3cc46b391cc60627"}, - {file = "wrapt-1.17.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:948a9bd0fb2c5120457b07e59c8d7210cbc8703243225dbd78f4dfc13c8d2d1f"}, - {file = "wrapt-1.17.0-cp38-cp38-win32.whl", hash = "sha256:5ae271862b2142f4bc687bdbfcc942e2473a89999a54231aa1c2c676e28f29ea"}, - {file = "wrapt-1.17.0-cp38-cp38-win_amd64.whl", hash = "sha256:f335579a1b485c834849e9075191c9898e0731af45705c2ebf70e0cd5d58beed"}, - {file = "wrapt-1.17.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d751300b94e35b6016d4b1e7d0e7bbc3b5e1751e2405ef908316c2a9024008a1"}, - {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7264cbb4a18dc4acfd73b63e4bcfec9c9802614572025bdd44d0721983fc1d9c"}, - {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:33539c6f5b96cf0b1105a0ff4cf5db9332e773bb521cc804a90e58dc49b10578"}, - {file = "wrapt-1.17.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c30970bdee1cad6a8da2044febd824ef6dc4cc0b19e39af3085c763fdec7de33"}, - {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:bc7f729a72b16ee21795a943f85c6244971724819819a41ddbaeb691b2dd85ad"}, - {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:6ff02a91c4fc9b6a94e1c9c20f62ea06a7e375f42fe57587f004d1078ac86ca9"}, - {file = "wrapt-1.17.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2dfb7cff84e72e7bf975b06b4989477873dcf160b2fd89959c629535df53d4e0"}, - {file = "wrapt-1.17.0-cp39-cp39-win32.whl", hash = "sha256:2399408ac33ffd5b200480ee858baa58d77dd30e0dd0cab6a8a9547135f30a88"}, - {file = "wrapt-1.17.0-cp39-cp39-win_amd64.whl", hash = "sha256:4f763a29ee6a20c529496a20a7bcb16a73de27f5da6a843249c7047daf135977"}, - {file = "wrapt-1.17.0-py3-none-any.whl", hash = "sha256:d2c63b93548eda58abf5188e505ffed0229bf675f7c3090f8e36ad55b8cbc371"}, - {file = "wrapt-1.17.0.tar.gz", hash = "sha256:16187aa2317c731170a88ef35e8937ae0f533c402872c1ee5e6d079fcf320801"}, +groups = ["main"] +markers = "sys_platform == \"darwin\"" +files = [ + {file = "xattr-1.2.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3df4d8d91e2996c3c72a390ec82e8544acdcb6c7df67b954f1736ff37ea4293e"}, + {file = "xattr-1.2.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f5eec248976bbfa6c23df25d4995413df57dccf4161f6cbae36f643e99dbc397"}, + {file = "xattr-1.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fafecfdedf7e8d455443bec2c3edab8a93d64672619cd1a4ee043a806152e19c"}, + {file = "xattr-1.2.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c229e245c6c9a85d2fd7d07531498f837dd34670e556b552f73350f11edf000c"}, + {file = "xattr-1.2.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:376631e2383918fbc3dc9bcaeb9a533e319322d2cff1c119635849edf74e1126"}, + {file = "xattr-1.2.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2fbae24ab22afe078d549645501ecacaa17229e0b7769c8418fad69b51ad37c9"}, + {file = "xattr-1.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a161160211081d765ac41fa056f4f9b1051f027f08188730fbc9782d0dce623e"}, + {file = "xattr-1.2.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a542acf6c4e8221664b51b35e0160c44bd0ed1f2fd80019476f7698f4911e560"}, + {file = "xattr-1.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:034f075fc5a9391a1597a6c9a21cb57b688680f0f18ecf73b2efc22b8d330cff"}, + {file = "xattr-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:00c26c14c90058338993bb2d3e1cebf562e94ec516cafba64a8f34f74b9d18b4"}, + {file = "xattr-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b4f43dc644db87d5eb9484a9518c34a864cb2e588db34cffc42139bf55302a1c"}, + {file = "xattr-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c7602583fc643ca76576498e2319c7cef0b72aef1936701678589da6371b731b"}, + {file = "xattr-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90c3ad4a9205cceb64ec54616aa90aa42d140c8ae3b9710a0aaa2843a6f1aca7"}, + {file = "xattr-1.2.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83d87cfe19cd606fc0709d45a4d6efc276900797deced99e239566926a5afedf"}, + {file = "xattr-1.2.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c67dabd9ddc04ead63fbc85aed459c9afcc24abfc5bb3217fff7ec9a466faacb"}, + {file = "xattr-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9a18ee82d8ba2c17f1e8414bfeb421fa763e0fb4acbc1e124988ca1584ad32d5"}, + {file = "xattr-1.2.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:38de598c47b85185e745986a061094d2e706e9c2d9022210d2c738066990fe91"}, + {file = "xattr-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15e754e854bdaac366ad3f1c8fbf77f6668e8858266b4246e8c5f487eeaf1179"}, + {file = "xattr-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:daff0c1f5c5e4eaf758c56259c4f72631fa9619875e7a25554b6077dc73da964"}, + {file = "xattr-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:109b11fb3f73a0d4e199962f11230ab5f462e85a8021874f96c1732aa61148d5"}, + {file = "xattr-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c7c12968ce0bf798d8ba90194cef65de768bee9f51a684e022c74cab4218305"}, + {file = "xattr-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d37989dabf25ff18773e4aaeebcb65604b9528f8645f43e02bebaa363e3ae958"}, + {file = "xattr-1.2.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:165de92b0f2adafb336f936931d044619b9840e35ba01079f4dd288747b73714"}, + {file = "xattr-1.2.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82191c006ae4c609b22b9aea5f38f68fff022dc6884c4c0e1dba329effd4b288"}, + {file = "xattr-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2b2e9c87dc643b09d86befad218e921f6e65b59a4668d6262b85308de5dbd1dd"}, + {file = "xattr-1.2.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:14edd5d47d0bb92b23222c0bb6379abbddab01fb776b2170758e666035ecf3aa"}, + {file = "xattr-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12183d5eb104d4da787638c7dadf63b718472d92fec6dbe12994ea5d094d7863"}, + {file = "xattr-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c385ea93a18aeb6443a719eb6a6b1d7f7b143a4d1f2b08bc4fadfc429209e629"}, + {file = "xattr-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d39d7b36842c67ab3040bead7eb6d601e35fa0d6214ed20a43df4ec30b6f9f9"}, + {file = "xattr-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:320ef856bb817f4c40213b6de956dc440d0f23cdc62da3ea02239eb5147093f8"}, + {file = "xattr-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26d306bfb3b5641726f2ee0da6f63a2656aa7fdcfd15de61c476e3ca6bc3277e"}, + {file = "xattr-1.2.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c67e70d5d8136d328ad13f85b887ffa97690422f1a11fb29ab2f702cf66e825a"}, + {file = "xattr-1.2.0-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8904d3539afe1a84fc0b7f02fa91da60d2505adf2d5951dc855bf9e75fe322b2"}, + {file = "xattr-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2520516c1d058895eae00b2b2f10833514caea6dc6802eef1e431c474b5317ad"}, + {file = "xattr-1.2.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:29d06abbef4024b7469fcd0d4ade6d2290582350a4df95fcc48fa48b2e83246b"}, + {file = "xattr-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:093c75f7d9190be355b8e86da3f460b9bfe3d6a176f92852d44dcc3289aa10dc"}, + {file = "xattr-1.2.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ee3901db48de913dcef004c5d7b477a1f4aadff997445ef62907b10fdad57de"}, + {file = "xattr-1.2.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b837898a5225c7f7df731783cd78bae2ed81b84bacf020821f1cd2ab2d74de58"}, + {file = "xattr-1.2.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cedc281811e424ecf6a14208532f7ac646866f91f88e8eadd00d8fe535e505fd"}, + {file = "xattr-1.2.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf60577caa248f539e4e646090b10d6ad1f54189de9a7f1854c23fdef28f574e"}, + {file = "xattr-1.2.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:363724f33510d2e7c7e080b389271a1241cb4929a1d9294f89721152b4410972"}, + {file = "xattr-1.2.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97db00596865845efb72f3d565a1f82b01006c5bf5a87d8854a6afac43502593"}, + {file = "xattr-1.2.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:0b199ba31078f3e4181578595cd60400ee055b4399672169ceee846d33ff26de"}, + {file = "xattr-1.2.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:b19472dc38150ac09a478c71092738d86882bc9ff687a4a8f7d1a25abce20b5e"}, + {file = "xattr-1.2.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:79f7823b30ed557e0e7ffd9a6b1a821a22f485f5347e54b8d24c4a34b7545ba4"}, + {file = "xattr-1.2.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eee258f5774933cb972cff5c3388166374e678980d2a1f417d7d6f61d9ae172"}, + {file = "xattr-1.2.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:2a9de621eadf0466c391363bd6ed903b1a1bcd272422b5183fd06ef79d05347b"}, + {file = "xattr-1.2.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:bc714f236f17c57c510ae9ada9962d8e4efc9f9ea91504e2c6a09008f3918ddf"}, + {file = "xattr-1.2.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:545e0ad3f706724029efd23dec58fb358422ae68ab4b560b712aedeaf40446a0"}, + {file = "xattr-1.2.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:200bb3cdba057cb721b727607bc340a74c28274f4a628a26011f574860f5846b"}, + {file = "xattr-1.2.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9b0b27c889cc9ff0dba62ac8a2eef98f4911c1621e4e8c409d5beb224c4c227c"}, + {file = "xattr-1.2.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:ea7cf8afd717853ad78eba8ca83ff66a53484ba2bb2a4283462bc5c767518174"}, + {file = "xattr-1.2.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:02fa813db054bbb7a61c570ae025bd01c36fc20727b40f49031feb930234bc72"}, + {file = "xattr-1.2.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:2827e23d7a1a20f31162c47ab4bd341a31e83421121978c4ab2aad5cd79ea82b"}, + {file = "xattr-1.2.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:29ae44247d46e63671311bf7e700826a97921278e2c0c04c2d11741888db41b8"}, + {file = "xattr-1.2.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:629c42c1dd813442d90f281f69b88ef0c9625f604989bef8411428671f70f43e"}, + {file = "xattr-1.2.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:549f8fbda5da48cafc81ba6ab7bb8e8e14c4b0748c37963dc504bcae505474b7"}, + {file = "xattr-1.2.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa83e677b5f92a3c5c86eaf875e9d3abbc43887ff1767178def865fa9f12a3a0"}, + {file = "xattr-1.2.0-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb669f01627962ce2bc556f19d421162247bc2cad0d4625d6ea5eb32af4cf29b"}, + {file = "xattr-1.2.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:212156aa5fb987a53211606bc09e6fea3eda3855af9f2940e40df5a2a592425a"}, + {file = "xattr-1.2.0-pp38-pypy38_pp73-macosx_11_0_arm64.whl", hash = "sha256:7dc4fa9448a513077c5ccd1ce428ff0682cdddfc71301dbbe4ee385c74517f73"}, + {file = "xattr-1.2.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1e4b93f2e74793b61c0a7b7bdef4a3813930df9c01eda72fad706b8db7658bc2"}, + {file = "xattr-1.2.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dddd5f6d0bb95b099d6a3888c248bf246525647ccb8cf9e8f0fc3952e012d6fb"}, + {file = "xattr-1.2.0-pp38-pypy38_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68fbdffebe8c398a82c84ecf5e6f6a3adde9364f891cba066e58352af404a45c"}, + {file = "xattr-1.2.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c9ee84de7cd4a6d61b0b79e2f58a6bdb13b03dbad948489ebb0b73a95caee7ae"}, + {file = "xattr-1.2.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5594fcbc38fdbb3af16a8ad18c37c81c8814955f0d636be857a67850cd556490"}, + {file = "xattr-1.2.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:017aac8005e1e84d5efa4b86c0896c6eb96f2331732d388600a5b999166fec1c"}, + {file = "xattr-1.2.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d27a64f695440450c119ae4bc8f54b0b726a812ebea1666fff3873236936f36"}, + {file = "xattr-1.2.0-pp39-pypy39_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f7e7067e1a400ad4485536a9e84c3330373086b2324fafa26d07527eeb4b175"}, + {file = "xattr-1.2.0.tar.gz", hash = "sha256:a64c8e21eff1be143accf80fd3b8fde3e28a478c37da298742af647ac3e5e0a7"}, ] +[package.dependencies] +cffi = ">=1.16.0" + +[package.extras] +test = ["pytest"] + [[package]] name = "yarl" -version = "1.18.3" +version = "1.20.1" description = "Yet another URL library" optional = false python-versions = ">=3.9" -files = [ - {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7df647e8edd71f000a5208fe6ff8c382a1de8edfbccdbbfe649d263de07d8c34"}, - {file = "yarl-1.18.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c69697d3adff5aa4f874b19c0e4ed65180ceed6318ec856ebc423aa5850d84f7"}, - {file = "yarl-1.18.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:602d98f2c2d929f8e697ed274fbadc09902c4025c5a9963bf4e9edfc3ab6f7ed"}, - {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c654d5207c78e0bd6d749f6dae1dcbbfde3403ad3a4b11f3c5544d9906969dde"}, - {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5094d9206c64181d0f6e76ebd8fb2f8fe274950a63890ee9e0ebfd58bf9d787b"}, - {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35098b24e0327fc4ebdc8ffe336cee0a87a700c24ffed13161af80124b7dc8e5"}, - {file = "yarl-1.18.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3236da9272872443f81fedc389bace88408f64f89f75d1bdb2256069a8730ccc"}, - {file = "yarl-1.18.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2c08cc9b16f4f4bc522771d96734c7901e7ebef70c6c5c35dd0f10845270bcd"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80316a8bd5109320d38eef8833ccf5f89608c9107d02d2a7f985f98ed6876990"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:c1e1cc06da1491e6734f0ea1e6294ce00792193c463350626571c287c9a704db"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fea09ca13323376a2fdfb353a5fa2e59f90cd18d7ca4eaa1fd31f0a8b4f91e62"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:e3b9fd71836999aad54084906f8663dffcd2a7fb5cdafd6c37713b2e72be1760"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:757e81cae69244257d125ff31663249b3013b5dc0a8520d73694aed497fb195b"}, - {file = "yarl-1.18.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b1771de9944d875f1b98a745bc547e684b863abf8f8287da8466cf470ef52690"}, - {file = "yarl-1.18.3-cp310-cp310-win32.whl", hash = "sha256:8874027a53e3aea659a6d62751800cf6e63314c160fd607489ba5c2edd753cf6"}, - {file = "yarl-1.18.3-cp310-cp310-win_amd64.whl", hash = "sha256:93b2e109287f93db79210f86deb6b9bbb81ac32fc97236b16f7433db7fc437d8"}, - {file = "yarl-1.18.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8503ad47387b8ebd39cbbbdf0bf113e17330ffd339ba1144074da24c545f0069"}, - {file = "yarl-1.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:02ddb6756f8f4517a2d5e99d8b2f272488e18dd0bfbc802f31c16c6c20f22193"}, - {file = "yarl-1.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:67a283dd2882ac98cc6318384f565bffc751ab564605959df4752d42483ad889"}, - {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d980e0325b6eddc81331d3f4551e2a333999fb176fd153e075c6d1c2530aa8a8"}, - {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b643562c12680b01e17239be267bc306bbc6aac1f34f6444d1bded0c5ce438ca"}, - {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c017a3b6df3a1bd45b9fa49a0f54005e53fbcad16633870104b66fa1a30a29d8"}, - {file = "yarl-1.18.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75674776d96d7b851b6498f17824ba17849d790a44d282929c42dbb77d4f17ae"}, - {file = "yarl-1.18.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ccaa3a4b521b780a7e771cc336a2dba389a0861592bbce09a476190bb0c8b4b3"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2d06d3005e668744e11ed80812e61efd77d70bb7f03e33c1598c301eea20efbb"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:9d41beda9dc97ca9ab0b9888cb71f7539124bc05df02c0cff6e5acc5a19dcc6e"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ba23302c0c61a9999784e73809427c9dbedd79f66a13d84ad1b1943802eaaf59"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6748dbf9bfa5ba1afcc7556b71cda0d7ce5f24768043a02a58846e4a443d808d"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:0b0cad37311123211dc91eadcb322ef4d4a66008d3e1bdc404808992260e1a0e"}, - {file = "yarl-1.18.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0fb2171a4486bb075316ee754c6d8382ea6eb8b399d4ec62fde2b591f879778a"}, - {file = "yarl-1.18.3-cp311-cp311-win32.whl", hash = "sha256:61b1a825a13bef4a5f10b1885245377d3cd0bf87cba068e1d9a88c2ae36880e1"}, - {file = "yarl-1.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:b9d60031cf568c627d028239693fd718025719c02c9f55df0a53e587aab951b5"}, - {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1dd4bdd05407ced96fed3d7f25dbbf88d2ffb045a0db60dbc247f5b3c5c25d50"}, - {file = "yarl-1.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7c33dd1931a95e5d9a772d0ac5e44cac8957eaf58e3c8da8c1414de7dd27c576"}, - {file = "yarl-1.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:25b411eddcfd56a2f0cd6a384e9f4f7aa3efee14b188de13048c25b5e91f1640"}, - {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:436c4fc0a4d66b2badc6c5fc5ef4e47bb10e4fd9bf0c79524ac719a01f3607c2"}, - {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e35ef8683211db69ffe129a25d5634319a677570ab6b2eba4afa860f54eeaf75"}, - {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84b2deecba4a3f1a398df819151eb72d29bfeb3b69abb145a00ddc8d30094512"}, - {file = "yarl-1.18.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00e5a1fea0fd4f5bfa7440a47eff01d9822a65b4488f7cff83155a0f31a2ecba"}, - {file = "yarl-1.18.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d0e883008013c0e4aef84dcfe2a0b172c4d23c2669412cf5b3371003941f72bb"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a3f356548e34a70b0172d8890006c37be92995f62d95a07b4a42e90fba54272"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ccd17349166b1bee6e529b4add61727d3f55edb7babbe4069b5764c9587a8cc6"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b958ddd075ddba5b09bb0be8a6d9906d2ce933aee81100db289badbeb966f54e"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c7d79f7d9aabd6011004e33b22bc13056a3e3fb54794d138af57f5ee9d9032cb"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4891ed92157e5430874dad17b15eb1fda57627710756c27422200c52d8a4e393"}, - {file = "yarl-1.18.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce1af883b94304f493698b00d0f006d56aea98aeb49d75ec7d98cd4a777e9285"}, - {file = "yarl-1.18.3-cp312-cp312-win32.whl", hash = "sha256:f91c4803173928a25e1a55b943c81f55b8872f0018be83e3ad4938adffb77dd2"}, - {file = "yarl-1.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:7e2ee16578af3b52ac2f334c3b1f92262f47e02cc6193c598502bd46f5cd1477"}, - {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:90adb47ad432332d4f0bc28f83a5963f426ce9a1a8809f5e584e704b82685dcb"}, - {file = "yarl-1.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:913829534200eb0f789d45349e55203a091f45c37a2674678744ae52fae23efa"}, - {file = "yarl-1.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ef9f7768395923c3039055c14334ba4d926f3baf7b776c923c93d80195624782"}, - {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88a19f62ff30117e706ebc9090b8ecc79aeb77d0b1f5ec10d2d27a12bc9f66d0"}, - {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e17c9361d46a4d5addf777c6dd5eab0715a7684c2f11b88c67ac37edfba6c482"}, - {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a74a13a4c857a84a845505fd2d68e54826a2cd01935a96efb1e9d86c728e186"}, - {file = "yarl-1.18.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41f7ce59d6ee7741af71d82020346af364949314ed3d87553763a2df1829cc58"}, - {file = "yarl-1.18.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f52a265001d830bc425f82ca9eabda94a64a4d753b07d623a9f2863fde532b53"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:82123d0c954dc58db301f5021a01854a85bf1f3bb7d12ae0c01afc414a882ca2"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:2ec9bbba33b2d00999af4631a3397d1fd78290c48e2a3e52d8dd72db3a067ac8"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fbd6748e8ab9b41171bb95c6142faf068f5ef1511935a0aa07025438dd9a9bc1"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:877d209b6aebeb5b16c42cbb377f5f94d9e556626b1bfff66d7b0d115be88d0a"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b464c4ab4bfcb41e3bfd3f1c26600d038376c2de3297760dfe064d2cb7ea8e10"}, - {file = "yarl-1.18.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8d39d351e7faf01483cc7ff7c0213c412e38e5a340238826be7e0e4da450fdc8"}, - {file = "yarl-1.18.3-cp313-cp313-win32.whl", hash = "sha256:61ee62ead9b68b9123ec24bc866cbef297dd266175d53296e2db5e7f797f902d"}, - {file = "yarl-1.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:578e281c393af575879990861823ef19d66e2b1d0098414855dd367e234f5b3c"}, - {file = "yarl-1.18.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:61e5e68cb65ac8f547f6b5ef933f510134a6bf31bb178be428994b0cb46c2a04"}, - {file = "yarl-1.18.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:fe57328fbc1bfd0bd0514470ac692630f3901c0ee39052ae47acd1d90a436719"}, - {file = "yarl-1.18.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a440a2a624683108a1b454705ecd7afc1c3438a08e890a1513d468671d90a04e"}, - {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09c7907c8548bcd6ab860e5f513e727c53b4a714f459b084f6580b49fa1b9cee"}, - {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b4f6450109834af88cb4cc5ecddfc5380ebb9c228695afc11915a0bf82116789"}, - {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a9ca04806f3be0ac6d558fffc2fdf8fcef767e0489d2684a21912cc4ed0cd1b8"}, - {file = "yarl-1.18.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77a6e85b90a7641d2e07184df5557132a337f136250caafc9ccaa4a2a998ca2c"}, - {file = "yarl-1.18.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6333c5a377c8e2f5fae35e7b8f145c617b02c939d04110c76f29ee3676b5f9a5"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:0b3c92fa08759dbf12b3a59579a4096ba9af8dd344d9a813fc7f5070d86bbab1"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:4ac515b860c36becb81bb84b667466885096b5fc85596948548b667da3bf9f24"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:045b8482ce9483ada4f3f23b3774f4e1bf4f23a2d5c912ed5170f68efb053318"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:a4bb030cf46a434ec0225bddbebd4b89e6471814ca851abb8696170adb163985"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:54d6921f07555713b9300bee9c50fb46e57e2e639027089b1d795ecd9f7fa910"}, - {file = "yarl-1.18.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1d407181cfa6e70077df3377938c08012d18893f9f20e92f7d2f314a437c30b1"}, - {file = "yarl-1.18.3-cp39-cp39-win32.whl", hash = "sha256:ac36703a585e0929b032fbaab0707b75dc12703766d0b53486eabd5139ebadd5"}, - {file = "yarl-1.18.3-cp39-cp39-win_amd64.whl", hash = "sha256:ba87babd629f8af77f557b61e49e7c7cac36f22f871156b91e10a6e9d4f829e9"}, - {file = "yarl-1.18.3-py3-none-any.whl", hash = "sha256:b57f4f58099328dfb26c6a771d09fb20dbbae81d20cfb66141251ea063bd101b"}, - {file = "yarl-1.18.3.tar.gz", hash = "sha256:ac1801c45cbf77b6c99242eeff4fffb5e4e73a800b5c4ad4fc0be5def634d2e1"}, +groups = ["main"] +files = [ + {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:6032e6da6abd41e4acda34d75a816012717000fa6839f37124a47fcefc49bec4"}, + {file = "yarl-1.20.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2c7b34d804b8cf9b214f05015c4fee2ebe7ed05cf581e7192c06555c71f4446a"}, + {file = "yarl-1.20.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0c869f2651cc77465f6cd01d938d91a11d9ea5d798738c1dc077f3de0b5e5fed"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62915e6688eb4d180d93840cda4110995ad50c459bf931b8b3775b37c264af1e"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:41ebd28167bc6af8abb97fec1a399f412eec5fd61a3ccbe2305a18b84fb4ca73"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:21242b4288a6d56f04ea193adde174b7e347ac46ce6bc84989ff7c1b1ecea84e"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bea21cdae6c7eb02ba02a475f37463abfe0a01f5d7200121b03e605d6a0439f8"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f8a891e4a22a89f5dde7862994485e19db246b70bb288d3ce73a34422e55b23"}, + {file = "yarl-1.20.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd803820d44c8853a109a34e3660e5a61beae12970da479cf44aa2954019bf70"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b982fa7f74c80d5c0c7b5b38f908971e513380a10fecea528091405f519b9ebb"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:33f29ecfe0330c570d997bcf1afd304377f2e48f61447f37e846a6058a4d33b2"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:835ab2cfc74d5eb4a6a528c57f05688099da41cf4957cf08cad38647e4a83b30"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:46b5e0ccf1943a9a6e766b2c2b8c732c55b34e28be57d8daa2b3c1d1d4009309"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:df47c55f7d74127d1b11251fe6397d84afdde0d53b90bedb46a23c0e534f9d24"}, + {file = "yarl-1.20.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:76d12524d05841276b0e22573f28d5fbcb67589836772ae9244d90dd7d66aa13"}, + {file = "yarl-1.20.1-cp310-cp310-win32.whl", hash = "sha256:6c4fbf6b02d70e512d7ade4b1f998f237137f1417ab07ec06358ea04f69134f8"}, + {file = "yarl-1.20.1-cp310-cp310-win_amd64.whl", hash = "sha256:aef6c4d69554d44b7f9d923245f8ad9a707d971e6209d51279196d8e8fe1ae16"}, + {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:47ee6188fea634bdfaeb2cc420f5b3b17332e6225ce88149a17c413c77ff269e"}, + {file = "yarl-1.20.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0f6500f69e8402d513e5eedb77a4e1818691e8f45e6b687147963514d84b44b"}, + {file = "yarl-1.20.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a8900a42fcdaad568de58887c7b2f602962356908eedb7628eaf6021a6e435b"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bad6d131fda8ef508b36be3ece16d0902e80b88ea7200f030a0f6c11d9e508d4"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:df018d92fe22aaebb679a7f89fe0c0f368ec497e3dda6cb81a567610f04501f1"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f969afbb0a9b63c18d0feecf0db09d164b7a44a053e78a7d05f5df163e43833"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:812303eb4aa98e302886ccda58d6b099e3576b1b9276161469c25803a8db277d"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98c4a7d166635147924aa0bf9bfe8d8abad6fffa6102de9c99ea04a1376f91e8"}, + {file = "yarl-1.20.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12e768f966538e81e6e7550f9086a6236b16e26cd964cf4df35349970f3551cf"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fe41919b9d899661c5c28a8b4b0acf704510b88f27f0934ac7a7bebdd8938d5e"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:8601bc010d1d7780592f3fc1bdc6c72e2b6466ea34569778422943e1a1f3c389"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:daadbdc1f2a9033a2399c42646fbd46da7992e868a5fe9513860122d7fe7a73f"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:03aa1e041727cb438ca762628109ef1333498b122e4c76dd858d186a37cec845"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:642980ef5e0fa1de5fa96d905c7e00cb2c47cb468bfcac5a18c58e27dbf8d8d1"}, + {file = "yarl-1.20.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:86971e2795584fe8c002356d3b97ef6c61862720eeff03db2a7c86b678d85b3e"}, + {file = "yarl-1.20.1-cp311-cp311-win32.whl", hash = "sha256:597f40615b8d25812f14562699e287f0dcc035d25eb74da72cae043bb884d773"}, + {file = "yarl-1.20.1-cp311-cp311-win_amd64.whl", hash = "sha256:26ef53a9e726e61e9cd1cda6b478f17e350fb5800b4bd1cd9fe81c4d91cfeb2e"}, + {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdcc4cd244e58593a4379fe60fdee5ac0331f8eb70320a24d591a3be197b94a9"}, + {file = "yarl-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b29a2c385a5f5b9c7d9347e5812b6f7ab267193c62d282a540b4fc528c8a9d2a"}, + {file = "yarl-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1112ae8154186dfe2de4732197f59c05a83dc814849a5ced892b708033f40dc2"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90bbd29c4fe234233f7fa2b9b121fb63c321830e5d05b45153a2ca68f7d310ee"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:680e19c7ce3710ac4cd964e90dad99bf9b5029372ba0c7cbfcd55e54d90ea819"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4a979218c1fdb4246a05efc2cc23859d47c89af463a90b99b7c56094daf25a16"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:255b468adf57b4a7b65d8aad5b5138dce6a0752c139965711bdcb81bc370e1b6"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a97d67108e79cfe22e2b430d80d7571ae57d19f17cda8bb967057ca8a7bf5bfd"}, + {file = "yarl-1.20.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8570d998db4ddbfb9a590b185a0a33dbf8aafb831d07a5257b4ec9948df9cb0a"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:97c75596019baae7c71ccf1d8cc4738bc08134060d0adfcbe5642f778d1dca38"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1c48912653e63aef91ff988c5432832692ac5a1d8f0fb8a33091520b5bbe19ef"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4c3ae28f3ae1563c50f3d37f064ddb1511ecc1d5584e88c6b7c63cf7702a6d5f"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5e9642f27036283550f5f57dc6156c51084b458570b9d0d96100c8bebb186a8"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2c26b0c49220d5799f7b22c6838409ee9bc58ee5c95361a4d7831f03cc225b5a"}, + {file = "yarl-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:564ab3d517e3d01c408c67f2e5247aad4019dcf1969982aba3974b4093279004"}, + {file = "yarl-1.20.1-cp312-cp312-win32.whl", hash = "sha256:daea0d313868da1cf2fac6b2d3a25c6e3a9e879483244be38c8e6a41f1d876a5"}, + {file = "yarl-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:48ea7d7f9be0487339828a4de0360d7ce0efc06524a48e1810f945c45b813698"}, + {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0b5ff0fbb7c9f1b1b5ab53330acbfc5247893069e7716840c8e7d5bb7355038a"}, + {file = "yarl-1.20.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:14f326acd845c2b2e2eb38fb1346c94f7f3b01a4f5c788f8144f9b630bfff9a3"}, + {file = "yarl-1.20.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f60e4ad5db23f0b96e49c018596707c3ae89f5d0bd97f0ad3684bcbad899f1e7"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:49bdd1b8e00ce57e68ba51916e4bb04461746e794e7c4d4bbc42ba2f18297691"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:66252d780b45189975abfed839616e8fd2dbacbdc262105ad7742c6ae58f3e31"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:59174e7332f5d153d8f7452a102b103e2e74035ad085f404df2e40e663a22b28"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e3968ec7d92a0c0f9ac34d5ecfd03869ec0cab0697c91a45db3fbbd95fe1b653"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1a4fbb50e14396ba3d375f68bfe02215d8e7bc3ec49da8341fe3157f59d2ff5"}, + {file = "yarl-1.20.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11a62c839c3a8eac2410e951301309426f368388ff2f33799052787035793b02"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:041eaa14f73ff5a8986b4388ac6bb43a77f2ea09bf1913df7a35d4646db69e53"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:377fae2fef158e8fd9d60b4c8751387b8d1fb121d3d0b8e9b0be07d1b41e83dc"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1c92f4390e407513f619d49319023664643d3339bd5e5a56a3bebe01bc67ec04"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d25ddcf954df1754ab0f86bb696af765c5bfaba39b74095f27eececa049ef9a4"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:909313577e9619dcff8c31a0ea2aa0a2a828341d92673015456b3ae492e7317b"}, + {file = "yarl-1.20.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:793fd0580cb9664548c6b83c63b43c477212c0260891ddf86809e1c06c8b08f1"}, + {file = "yarl-1.20.1-cp313-cp313-win32.whl", hash = "sha256:468f6e40285de5a5b3c44981ca3a319a4b208ccc07d526b20b12aeedcfa654b7"}, + {file = "yarl-1.20.1-cp313-cp313-win_amd64.whl", hash = "sha256:495b4ef2fea40596bfc0affe3837411d6aa3371abcf31aac0ccc4bdd64d4ef5c"}, + {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:f60233b98423aab21d249a30eb27c389c14929f47be8430efa7dbd91493a729d"}, + {file = "yarl-1.20.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6f3eff4cc3f03d650d8755c6eefc844edde99d641d0dcf4da3ab27141a5f8ddf"}, + {file = "yarl-1.20.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:69ff8439d8ba832d6bed88af2c2b3445977eba9a4588b787b32945871c2444e3"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cf34efa60eb81dd2645a2e13e00bb98b76c35ab5061a3989c7a70f78c85006d"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8e0fe9364ad0fddab2688ce72cb7a8e61ea42eff3c7caeeb83874a5d479c896c"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f64fbf81878ba914562c672024089e3401974a39767747691c65080a67b18c1"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6342d643bf9a1de97e512e45e4b9560a043347e779a173250824f8b254bd5ce"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56dac5f452ed25eef0f6e3c6a066c6ab68971d96a9fb441791cad0efba6140d3"}, + {file = "yarl-1.20.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c7d7f497126d65e2cad8dc5f97d34c27b19199b6414a40cb36b52f41b79014be"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:67e708dfb8e78d8a19169818eeb5c7a80717562de9051bf2413aca8e3696bf16"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:595c07bc79af2494365cc96ddeb772f76272364ef7c80fb892ef9d0649586513"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7bdd2f80f4a7df852ab9ab49484a4dee8030023aa536df41f2d922fd57bf023f"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c03bfebc4ae8d862f853a9757199677ab74ec25424d0ebd68a0027e9c639a390"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:344d1103e9c1523f32a5ed704d576172d2cabed3122ea90b1d4e11fe17c66458"}, + {file = "yarl-1.20.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:88cab98aa4e13e1ade8c141daeedd300a4603b7132819c484841bb7af3edce9e"}, + {file = "yarl-1.20.1-cp313-cp313t-win32.whl", hash = "sha256:b121ff6a7cbd4abc28985b6028235491941b9fe8fe226e6fdc539c977ea1739d"}, + {file = "yarl-1.20.1-cp313-cp313t-win_amd64.whl", hash = "sha256:541d050a355bbbc27e55d906bc91cb6fe42f96c01413dd0f4ed5a5240513874f"}, + {file = "yarl-1.20.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e42ba79e2efb6845ebab49c7bf20306c4edf74a0b20fc6b2ccdd1a219d12fad3"}, + {file = "yarl-1.20.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:41493b9b7c312ac448b7f0a42a089dffe1d6e6e981a2d76205801a023ed26a2b"}, + {file = "yarl-1.20.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f5a5928ff5eb13408c62a968ac90d43f8322fd56d87008b8f9dabf3c0f6ee983"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30c41ad5d717b3961b2dd785593b67d386b73feca30522048d37298fee981805"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:59febc3969b0781682b469d4aca1a5cab7505a4f7b85acf6db01fa500fa3f6ba"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d2b6fb3622b7e5bf7a6e5b679a69326b4279e805ed1699d749739a61d242449e"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:749d73611db8d26a6281086f859ea7ec08f9c4c56cec864e52028c8b328db723"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9427925776096e664c39e131447aa20ec738bdd77c049c48ea5200db2237e000"}, + {file = "yarl-1.20.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ff70f32aa316393eaf8222d518ce9118148eddb8a53073c2403863b41033eed5"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c7ddf7a09f38667aea38801da8b8d6bfe81df767d9dfc8c88eb45827b195cd1c"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:57edc88517d7fc62b174fcfb2e939fbc486a68315d648d7e74d07fac42cec240"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:dab096ce479d5894d62c26ff4f699ec9072269d514b4edd630a393223f45a0ee"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:14a85f3bd2d7bb255be7183e5d7d6e70add151a98edf56a770d6140f5d5f4010"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c89b5c792685dd9cd3fa9761c1b9f46fc240c2a3265483acc1565769996a3f8"}, + {file = "yarl-1.20.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:69e9b141de5511021942a6866990aea6d111c9042235de90e08f94cf972ca03d"}, + {file = "yarl-1.20.1-cp39-cp39-win32.whl", hash = "sha256:b5f307337819cdfdbb40193cad84978a029f847b0a357fbe49f712063cfc4f06"}, + {file = "yarl-1.20.1-cp39-cp39-win_amd64.whl", hash = "sha256:eae7bfe2069f9c1c5b05fc7fe5d612e5bbc089a39309904ee8b829e322dcad00"}, + {file = "yarl-1.20.1-py3-none-any.whl", hash = "sha256:83b8eb083fe4683c6115795d9fc1cfaf2cbbefb19b3a1cb68f6527460f483a77"}, + {file = "yarl-1.20.1.tar.gz", hash = "sha256:d017a4997ee50c91fd5466cef416231bb82177b93b029906cefc542ce14c35ac"}, ] [package.dependencies] idna = ">=2.0" multidict = ">=4.0" -propcache = ">=0.2.0" +propcache = ">=0.2.1" [[package]] name = "youtube-transcript-api" -version = "0.6.3" +version = "1.2.1" description = "This is an python API which allows you to get the transcripts/subtitles for a given YouTube video. It also works for automatically generated subtitles, supports translating subtitles and it does not require a headless browser, like other selenium based solutions do!" optional = false python-versions = "<3.14,>=3.8" +groups = ["main"] files = [ - {file = "youtube_transcript_api-0.6.3-py3-none-any.whl", hash = "sha256:297a74c1863d9df88f6885229f33a7eda61493d73ecb13ec80e876b65423e9b4"}, - {file = "youtube_transcript_api-0.6.3.tar.gz", hash = "sha256:4d1f6451ae508390a5279f98519efb45e091bf60d3cca5ea0bb122800ab6a011"}, + {file = "youtube_transcript_api-1.2.1-py3-none-any.whl", hash = "sha256:4852356c8459aceab73f8f05e0f7fc4762d5f278e7e34bd6359fce7427df853b"}, + {file = "youtube_transcript_api-1.2.1.tar.gz", hash = "sha256:7c16ba3e981dd7ab4c0f00f42e5a69b19bdb9f13324c60bd6cc8f97701699900"}, ] [package.dependencies] defusedxml = ">=0.7.1,<0.8.0" requests = "*" +[[package]] +name = "zerobouncesdk" +version = "1.1.2" +description = "ZeroBounce Python API - https://www.zerobounce.net." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "zerobouncesdk-1.1.2-py3-none-any.whl", hash = "sha256:a89febfb3adade01c314e6bad2113ad093f1e1cca6ddf9fcf445a8b2a9a458b4"}, + {file = "zerobouncesdk-1.1.2.tar.gz", hash = "sha256:24810a2e39c963bc75b4732356b0fc8b10091f2c892f0c8b08fbb32640fdccaf"}, +] + +[package.dependencies] +requests = ">=2.22.0" + [[package]] name = "zipp" -version = "3.21.0" +version = "3.23.0" description = "Backport of pathlib-compatible object wrapper for zip files" optional = false python-versions = ">=3.9" +groups = ["main"] files = [ - {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, - {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, + {file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"}, + {file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"}, ] [package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] +check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""] cover = ["pytest-cov"] doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] +test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] type = ["pytest-mypy"] +[[package]] +name = "zstandard" +version = "0.23.0" +description = "Zstandard bindings for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "zstandard-0.23.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bf0a05b6059c0528477fba9054d09179beb63744355cab9f38059548fedd46a9"}, + {file = "zstandard-0.23.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fc9ca1c9718cb3b06634c7c8dec57d24e9438b2aa9a0f02b8bb36bf478538880"}, + {file = "zstandard-0.23.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77da4c6bfa20dd5ea25cbf12c76f181a8e8cd7ea231c673828d0386b1740b8dc"}, + {file = "zstandard-0.23.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b2170c7e0367dde86a2647ed5b6f57394ea7f53545746104c6b09fc1f4223573"}, + {file = "zstandard-0.23.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c16842b846a8d2a145223f520b7e18b57c8f476924bda92aeee3a88d11cfc391"}, + {file = "zstandard-0.23.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:157e89ceb4054029a289fb504c98c6a9fe8010f1680de0201b3eb5dc20aa6d9e"}, + {file = "zstandard-0.23.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:203d236f4c94cd8379d1ea61db2fce20730b4c38d7f1c34506a31b34edc87bdd"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:dc5d1a49d3f8262be192589a4b72f0d03b72dcf46c51ad5852a4fdc67be7b9e4"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:752bf8a74412b9892f4e5b58f2f890a039f57037f52c89a740757ebd807f33ea"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:80080816b4f52a9d886e67f1f96912891074903238fe54f2de8b786f86baded2"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84433dddea68571a6d6bd4fbf8ff398236031149116a7fff6f777ff95cad3df9"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ab19a2d91963ed9e42b4e8d77cd847ae8381576585bad79dbd0a8837a9f6620a"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:59556bf80a7094d0cfb9f5e50bb2db27fefb75d5138bb16fb052b61b0e0eeeb0"}, + {file = "zstandard-0.23.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:27d3ef2252d2e62476389ca8f9b0cf2bbafb082a3b6bfe9d90cbcbb5529ecf7c"}, + {file = "zstandard-0.23.0-cp310-cp310-win32.whl", hash = "sha256:5d41d5e025f1e0bccae4928981e71b2334c60f580bdc8345f824e7c0a4c2a813"}, + {file = "zstandard-0.23.0-cp310-cp310-win_amd64.whl", hash = "sha256:519fbf169dfac1222a76ba8861ef4ac7f0530c35dd79ba5727014613f91613d4"}, + {file = "zstandard-0.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34895a41273ad33347b2fc70e1bff4240556de3c46c6ea430a7ed91f9042aa4e"}, + {file = "zstandard-0.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:77ea385f7dd5b5676d7fd943292ffa18fbf5c72ba98f7d09fc1fb9e819b34c23"}, + {file = "zstandard-0.23.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:983b6efd649723474f29ed42e1467f90a35a74793437d0bc64a5bf482bedfa0a"}, + {file = "zstandard-0.23.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80a539906390591dd39ebb8d773771dc4db82ace6372c4d41e2d293f8e32b8db"}, + {file = "zstandard-0.23.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:445e4cb5048b04e90ce96a79b4b63140e3f4ab5f662321975679b5f6360b90e2"}, + {file = "zstandard-0.23.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd30d9c67d13d891f2360b2a120186729c111238ac63b43dbd37a5a40670b8ca"}, + {file = "zstandard-0.23.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d20fd853fbb5807c8e84c136c278827b6167ded66c72ec6f9a14b863d809211c"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ed1708dbf4d2e3a1c5c69110ba2b4eb6678262028afd6c6fbcc5a8dac9cda68e"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:be9b5b8659dff1f913039c2feee1aca499cfbc19e98fa12bc85e037c17ec6ca5"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:65308f4b4890aa12d9b6ad9f2844b7ee42c7f7a4fd3390425b242ffc57498f48"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:98da17ce9cbf3bfe4617e836d561e433f871129e3a7ac16d6ef4c680f13a839c"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed7d27cb56b3e058d3cf684d7200703bcae623e1dcc06ed1e18ecda39fee003"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:b69bb4f51daf461b15e7b3db033160937d3ff88303a7bc808c67bbc1eaf98c78"}, + {file = "zstandard-0.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:034b88913ecc1b097f528e42b539453fa82c3557e414b3de9d5632c80439a473"}, + {file = "zstandard-0.23.0-cp311-cp311-win32.whl", hash = "sha256:f2d4380bf5f62daabd7b751ea2339c1a21d1c9463f1feb7fc2bdcea2c29c3160"}, + {file = "zstandard-0.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:62136da96a973bd2557f06ddd4e8e807f9e13cbb0bfb9cc06cfe6d98ea90dfe0"}, + {file = "zstandard-0.23.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b4567955a6bc1b20e9c31612e615af6b53733491aeaa19a6b3b37f3b65477094"}, + {file = "zstandard-0.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1e172f57cd78c20f13a3415cc8dfe24bf388614324d25539146594c16d78fcc8"}, + {file = "zstandard-0.23.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0e166f698c5a3e914947388c162be2583e0c638a4703fc6a543e23a88dea3c1"}, + {file = "zstandard-0.23.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12a289832e520c6bd4dcaad68e944b86da3bad0d339ef7989fb7e88f92e96072"}, + {file = "zstandard-0.23.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d50d31bfedd53a928fed6707b15a8dbeef011bb6366297cc435accc888b27c20"}, + {file = "zstandard-0.23.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72c68dda124a1a138340fb62fa21b9bf4848437d9ca60bd35db36f2d3345f373"}, + {file = "zstandard-0.23.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53dd9d5e3d29f95acd5de6802e909ada8d8d8cfa37a3ac64836f3bc4bc5512db"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6a41c120c3dbc0d81a8e8adc73312d668cd34acd7725f036992b1b72d22c1772"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:40b33d93c6eddf02d2c19f5773196068d875c41ca25730e8288e9b672897c105"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9206649ec587e6b02bd124fb7799b86cddec350f6f6c14bc82a2b70183e708ba"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:76e79bc28a65f467e0409098fa2c4376931fd3207fbeb6b956c7c476d53746dd"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:66b689c107857eceabf2cf3d3fc699c3c0fe8ccd18df2219d978c0283e4c508a"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9c236e635582742fee16603042553d276cca506e824fa2e6489db04039521e90"}, + {file = "zstandard-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a8fffdbd9d1408006baaf02f1068d7dd1f016c6bcb7538682622c556e7b68e35"}, + {file = "zstandard-0.23.0-cp312-cp312-win32.whl", hash = "sha256:dc1d33abb8a0d754ea4763bad944fd965d3d95b5baef6b121c0c9013eaf1907d"}, + {file = "zstandard-0.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:64585e1dba664dc67c7cdabd56c1e5685233fbb1fc1966cfba2a340ec0dfff7b"}, + {file = "zstandard-0.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:576856e8594e6649aee06ddbfc738fec6a834f7c85bf7cadd1c53d4a58186ef9"}, + {file = "zstandard-0.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:38302b78a850ff82656beaddeb0bb989a0322a8bbb1bf1ab10c17506681d772a"}, + {file = "zstandard-0.23.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d2240ddc86b74966c34554c49d00eaafa8200a18d3a5b6ffbf7da63b11d74ee2"}, + {file = "zstandard-0.23.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2ef230a8fd217a2015bc91b74f6b3b7d6522ba48be29ad4ea0ca3a3775bf7dd5"}, + {file = "zstandard-0.23.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:774d45b1fac1461f48698a9d4b5fa19a69d47ece02fa469825b442263f04021f"}, + {file = "zstandard-0.23.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f77fa49079891a4aab203d0b1744acc85577ed16d767b52fc089d83faf8d8ed"}, + {file = "zstandard-0.23.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac184f87ff521f4840e6ea0b10c0ec90c6b1dcd0bad2f1e4a9a1b4fa177982ea"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c363b53e257246a954ebc7c488304b5592b9c53fbe74d03bc1c64dda153fb847"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e7792606d606c8df5277c32ccb58f29b9b8603bf83b48639b7aedf6df4fe8171"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a0817825b900fcd43ac5d05b8b3079937073d2b1ff9cf89427590718b70dd840"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:9da6bc32faac9a293ddfdcb9108d4b20416219461e4ec64dfea8383cac186690"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd7699e8fd9969f455ef2926221e0233f81a2542921471382e77a9e2f2b57f4b"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d477ed829077cd945b01fc3115edd132c47e6540ddcd96ca169facff28173057"}, + {file = "zstandard-0.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa6ce8b52c5987b3e34d5674b0ab529a4602b632ebab0a93b07bfb4dfc8f8a33"}, + {file = "zstandard-0.23.0-cp313-cp313-win32.whl", hash = "sha256:a9b07268d0c3ca5c170a385a0ab9fb7fdd9f5fd866be004c4ea39e44edce47dd"}, + {file = "zstandard-0.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:f3513916e8c645d0610815c257cbfd3242adfd5c4cfa78be514e5a3ebb42a41b"}, + {file = "zstandard-0.23.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2ef3775758346d9ac6214123887d25c7061c92afe1f2b354f9388e9e4d48acfc"}, + {file = "zstandard-0.23.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4051e406288b8cdbb993798b9a45c59a4896b6ecee2f875424ec10276a895740"}, + {file = "zstandard-0.23.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2d1a054f8f0a191004675755448d12be47fa9bebbcffa3cdf01db19f2d30a54"}, + {file = "zstandard-0.23.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f83fa6cae3fff8e98691248c9320356971b59678a17f20656a9e59cd32cee6d8"}, + {file = "zstandard-0.23.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32ba3b5ccde2d581b1e6aa952c836a6291e8435d788f656fe5976445865ae045"}, + {file = "zstandard-0.23.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f146f50723defec2975fb7e388ae3a024eb7151542d1599527ec2aa9cacb152"}, + {file = "zstandard-0.23.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1bfe8de1da6d104f15a60d4a8a768288f66aa953bbe00d027398b93fb9680b26"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:29a2bc7c1b09b0af938b7a8343174b987ae021705acabcbae560166567f5a8db"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:61f89436cbfede4bc4e91b4397eaa3e2108ebe96d05e93d6ccc95ab5714be512"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:53ea7cdc96c6eb56e76bb06894bcfb5dfa93b7adcf59d61c6b92674e24e2dd5e"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:a4ae99c57668ca1e78597d8b06d5af837f377f340f4cce993b551b2d7731778d"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:379b378ae694ba78cef921581ebd420c938936a153ded602c4fea612b7eaa90d"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:50a80baba0285386f97ea36239855f6020ce452456605f262b2d33ac35c7770b"}, + {file = "zstandard-0.23.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:61062387ad820c654b6a6b5f0b94484fa19515e0c5116faf29f41a6bc91ded6e"}, + {file = "zstandard-0.23.0-cp38-cp38-win32.whl", hash = "sha256:b8c0bd73aeac689beacd4e7667d48c299f61b959475cdbb91e7d3d88d27c56b9"}, + {file = "zstandard-0.23.0-cp38-cp38-win_amd64.whl", hash = "sha256:a05e6d6218461eb1b4771d973728f0133b2a4613a6779995df557f70794fd60f"}, + {file = "zstandard-0.23.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3aa014d55c3af933c1315eb4bb06dd0459661cc0b15cd61077afa6489bec63bb"}, + {file = "zstandard-0.23.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0a7f0804bb3799414af278e9ad51be25edf67f78f916e08afdb983e74161b916"}, + {file = "zstandard-0.23.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb2b1ecfef1e67897d336de3a0e3f52478182d6a47eda86cbd42504c5cbd009a"}, + {file = "zstandard-0.23.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:837bb6764be6919963ef41235fd56a6486b132ea64afe5fafb4cb279ac44f259"}, + {file = "zstandard-0.23.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1516c8c37d3a053b01c1c15b182f3b5f5eef19ced9b930b684a73bad121addf4"}, + {file = "zstandard-0.23.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48ef6a43b1846f6025dde6ed9fee0c24e1149c1c25f7fb0a0585572b2f3adc58"}, + {file = "zstandard-0.23.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:11e3bf3c924853a2d5835b24f03eeba7fc9b07d8ca499e247e06ff5676461a15"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:2fb4535137de7e244c230e24f9d1ec194f61721c86ebea04e1581d9d06ea1269"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8c24f21fa2af4bb9f2c492a86fe0c34e6d2c63812a839590edaf177b7398f700"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a8c86881813a78a6f4508ef9daf9d4995b8ac2d147dcb1a450448941398091c9"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:fe3b385d996ee0822fd46528d9f0443b880d4d05528fd26a9119a54ec3f91c69"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:82d17e94d735c99621bf8ebf9995f870a6b3e6d14543b99e201ae046dfe7de70"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:c7c517d74bea1a6afd39aa612fa025e6b8011982a0897768a2f7c8ab4ebb78a2"}, + {file = "zstandard-0.23.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:1fd7e0f1cfb70eb2f95a19b472ee7ad6d9a0a992ec0ae53286870c104ca939e5"}, + {file = "zstandard-0.23.0-cp39-cp39-win32.whl", hash = "sha256:43da0f0092281bf501f9c5f6f3b4c975a8a0ea82de49ba3f7100e64d422a1274"}, + {file = "zstandard-0.23.0-cp39-cp39-win_amd64.whl", hash = "sha256:f8346bfa098532bc1fb6c7ef06783e969d87a99dd1d2a5a18a892c1d7a643c58"}, + {file = "zstandard-0.23.0.tar.gz", hash = "sha256:b2d8c62d08e7255f68f7a740bae85b3c9b8e5466baa9cbf7f57f1cde0ac6bc09"}, +] + +[package.dependencies] +cffi = {version = ">=1.11", markers = "platform_python_implementation == \"PyPy\""} + +[package.extras] +cffi = ["cffi (>=1.11)"] + [metadata] -lock-version = "2.0" -python-versions = ">=3.10,<3.13" -content-hash = "180e010fe51c228a973a76984523deedda9ebc9b6059ec4a5c5adbe76ce4509e" +lock-version = "2.1" +python-versions = ">=3.10,<3.14" +content-hash = "06ca298144a75656a31df830153013cc372f47bed0fd04c8cb02373a4f40c38b" diff --git a/autogpt_platform/backend/pyproject.toml b/autogpt_platform/backend/pyproject.toml index 447861faf210..424e7635e795 100644 --- a/autogpt_platform/backend/pyproject.toml +++ b/autogpt_platform/backend/pyproject.toml @@ -1,72 +1,99 @@ [tool.poetry] name = "autogpt-platform-backend" -version = "0.3.4" +version = "0.6.22" description = "A platform for building AI-powered agentic workflows" authors = ["AutoGPT "] readme = "README.md" -packages = [{ include = "backend" }] +packages = [{ include = "backend", format = "sdist" }] [tool.poetry.dependencies] -python = ">=3.10,<3.13" -aio-pika = "^9.5.4" -anthropic = "^0.40.0" +python = ">=3.10,<3.14" +aio-pika = "^9.5.5" +aiohttp = "^3.10.0" +aiodns = "^3.5.0" +anthropic = "^0.59.0" apscheduler = "^3.11.0" autogpt-libs = { path = "../autogpt_libs", develop = true } -click = "^8.1.7" -croniter = "^5.0.1" -discord-py = "^2.4.0" -e2b-code-interpreter = "^1.0.1" -fastapi = "^0.115.5" +bleach = { extras = ["css"], version = "^6.2.0" } +click = "^8.2.0" +cryptography = "^43.0" +discord-py = "^2.5.2" +e2b-code-interpreter = "^1.5.2" +fastapi = "^0.116.1" feedparser = "^6.0.11" -flake8 = "^7.0.0" -google-api-python-client = "^2.154.0" -google-auth-oauthlib = "^1.2.1" -groq = "^0.13.1" -jinja2 = "^3.1.4" +flake8 = "^7.3.0" +google-api-python-client = "^2.177.0" +google-auth-oauthlib = "^1.2.2" +google-cloud-storage = "^3.2.0" +googlemaps = "^4.10.0" +gravitasml = "^0.1.3" +groq = "^0.30.0" +html2text = "^2024.2.26" +jinja2 = "^3.1.6" jsonref = "^1.1.0" -jsonschema = "^4.22.0" -ollama = "^0.4.1" -openai = "^1.57.4" +jsonschema = "^4.25.0" +launchdarkly-server-sdk = "^9.12.0" +mem0ai = "^0.1.115" +moviepy = "^2.1.2" +ollama = "^0.5.1" +openai = "^1.97.1" +pika = "^1.3.2" +pinecone = "^7.3.0" +poetry = "2.1.1" # CHECK DEPENDABOT SUPPORT BEFORE UPGRADING +postmarker = "^1.0" praw = "~7.8.1" prisma = "^0.15.0" -psutil = "^6.1.0" -pydantic = "^2.9.2" -pydantic-settings = "^2.3.4" -pyro5 = "^5.15" -pytest = "^8.2.1" -pytest-asyncio = "^0.25.0" -python-dotenv = "^1.0.1" -redis = "^5.2.0" -sentry-sdk = "2.19.2" -strenum = "^0.4.9" -supabase = "^2.10.0" -tenacity = "^9.0.0" -uvicorn = { extras = ["standard"], version = "^0.34.0" } -websockets = "^13.1" -youtube-transcript-api = "^0.6.2" -googlemaps = "^4.10.0" -replicate = "^1.0.4" -pinecone = "^5.3.1" -cryptography = "^43.0" -python-multipart = "^0.0.20" -sqlalchemy = "^2.0.36" +prometheus-client = "^0.22.1" +psutil = "^7.0.0" psycopg2-binary = "^2.9.10" -google-cloud-storage = "^2.18.2" -launchdarkly-server-sdk = "^9.8.0" +pydantic = { extras = ["email"], version = "^2.11.7" } +pydantic-settings = "^2.10.1" +pytest = "^8.4.1" +pytest-asyncio = "^1.1.0" +python-dotenv = "^1.1.1" +python-multipart = "^0.0.20" +redis = "^6.2.0" +replicate = "^1.0.6" +sentry-sdk = {extras = ["anthropic", "fastapi", "launchdarkly", "openai", "sqlalchemy"], version = "^2.33.2"} +sqlalchemy = "^2.0.40" +strenum = "^0.4.9" +stripe = "^11.5.0" +supabase = "2.17.0" +tenacity = "^9.1.2" +todoist-api-python = "^2.1.7" +tweepy = "^4.16.0" +uvicorn = { extras = ["standard"], version = "^0.35.0" } +websockets = "^15.0" +youtube-transcript-api = "^1.2.1" +zerobouncesdk = "^1.1.2" +# NOTE: please insert new dependencies in their alphabetical location +pytest-snapshot = "^0.9.0" +aiofiles = "^24.1.0" +tiktoken = "^0.9.0" +aioclamd = "^1.0.0" +setuptools = "^80.9.0" +gcloud-aio-storage = "^9.5.0" +pandas = "^2.3.1" +firecrawl-py = "^2.16.3" +exa-py = "^1.14.20" +croniter = "^6.0.0" +stagehand = "^0.5.1" [tool.poetry.group.dev.dependencies] -poethepoet = "^0.31.0" -httpx = "^0.27.0" -pytest-watcher = "^0.4.2" -requests = "^2.32.3" -ruff = "^0.8.0" -pyright = "^1.1.389" -isort = "^5.13.2" +aiohappyeyeballs = "^2.6.1" black = "^24.10.0" -aiohappyeyeballs = "^2.4.3" +faker = "^37.4.2" +httpx = "^0.28.1" +isort = "^5.13.2" +poethepoet = "^0.36.0" +pre-commit = "^4.2.0" +pyright = "^1.1.403" pytest-mock = "^3.14.0" -faker = "^33.1.0" +pytest-watcher = "^0.4.2" +requests = "^2.32.4" +ruff = "^0.12.3" +# NOTE: please insert new dependencies in their alphabetical location [build-system] requires = ["poetry-core"] @@ -75,7 +102,10 @@ build-backend = "poetry.core.masonry.api" [tool.poetry.scripts] app = "backend.app:main" rest = "backend.rest:main" +db = "backend.db:main" ws = "backend.ws:main" +scheduler = "backend.scheduler:main" +notification = "backend.notification:main" executor = "backend.exec:main" cli = "backend.cli:main" format = "linter:format" @@ -96,6 +126,11 @@ ignore_patterns = [] [tool.pytest.ini_options] asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +filterwarnings = [ + "ignore:'audioop' is deprecated:DeprecationWarning:discord.player", + "ignore:invalid escape sequence:DeprecationWarning:tweepy.api", +] [tool.ruff] target-version = "py310" diff --git a/autogpt_platform/backend/run_test_data.py b/autogpt_platform/backend/run_test_data.py new file mode 100644 index 000000000000..b37660729227 --- /dev/null +++ b/autogpt_platform/backend/run_test_data.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +Run test data creation and update scripts in sequence. + +Usage: + poetry run python run_test_data.py +""" + +import asyncio +import subprocess +import sys +from pathlib import Path + + +def run_command(cmd: list[str], cwd: Path | None = None) -> bool: + """Run a command and return True if successful.""" + try: + result = subprocess.run( + cmd, check=True, capture_output=True, text=True, cwd=cwd + ) + if result.stdout: + print(result.stdout) + return True + except subprocess.CalledProcessError as e: + print(f"Error running command: {' '.join(cmd)}") + print(f"Error: {e.stderr}") + return False + + +async def main(): + """Main function to run test data scripts.""" + print("=" * 60) + print("Running Test Data Scripts for AutoGPT Platform") + print("=" * 60) + print() + + # Get the backend directory + backend_dir = Path(__file__).parent + test_dir = backend_dir / "test" + + # Check if we're in the right directory + if not (backend_dir / "pyproject.toml").exists(): + print("ERROR: This script must be run from the backend directory") + sys.exit(1) + + print("1. Checking database connection...") + print("-" * 40) + + # Import here to ensure proper environment setup + try: + from prisma import Prisma + + db = Prisma() + await db.connect() + print("✓ Database connection successful") + await db.disconnect() + except Exception as e: + print(f"✗ Database connection failed: {e}") + print("\nPlease ensure:") + print("1. The database services are running (docker compose up -d)") + print("2. The DATABASE_URL in .env is correct") + print("3. Migrations have been run (poetry run prisma migrate deploy)") + sys.exit(1) + + print() + print("2. Running test data creator...") + print("-" * 40) + + # Run test_data_creator.py + if run_command(["poetry", "run", "python", "test_data_creator.py"], cwd=test_dir): + print() + print("✅ Test data created successfully!") + + print() + print("3. Running test data updater...") + print("-" * 40) + + # Run test_data_updater.py + if run_command( + ["poetry", "run", "python", "test_data_updater.py"], cwd=test_dir + ): + print() + print("✅ Test data updated successfully!") + else: + print() + print("❌ Test data updater failed!") + sys.exit(1) + else: + print() + print("❌ Test data creator failed!") + sys.exit(1) + + print() + print("=" * 60) + print("Test data setup completed successfully!") + print("=" * 60) + print() + print("The materialized views have been populated with test data:") + print("- mv_agent_run_counts: Agent execution statistics") + print("- mv_review_stats: Store listing review statistics") + print() + print("You can now:") + print("1. Run tests: poetry run test") + print("2. Start the backend: poetry run serve") + print("3. View data in the database") + print() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/autogpt_platform/backend/run_tests.py b/autogpt_platform/backend/run_tests.py index b2343b2082ca..e6aa189d50f6 100644 --- a/autogpt_platform/backend/run_tests.py +++ b/autogpt_platform/backend/run_tests.py @@ -1,3 +1,4 @@ +import os import subprocess import sys import time @@ -12,8 +13,10 @@ def wait_for_postgres(max_retries=5, delay=5): "compose", "-f", "docker-compose.test.yaml", + "--env-file", + "../.env", "exec", - "postgres-test", + "db", "pg_isready", "-U", "postgres", @@ -50,9 +53,10 @@ def test(): "compose", "-f", "docker-compose.test.yaml", + "--env-file", + "../.env", "up", "-d", - "postgres-test", ] ) @@ -60,11 +64,61 @@ def test(): run_command(["docker", "compose", "-f", "docker-compose.test.yaml", "down"]) sys.exit(1) - # Run Prisma migrations - run_command(["prisma", "migrate", "dev"]) + # IMPORTANT: Set test database environment variables to prevent accidentally + # resetting the developer's local database. + # + # This script spins up a separate test database container (postgres-test) using + # docker-compose.test.yaml. We explicitly set DATABASE_URL and DIRECT_URL to point + # to this test database to ensure that: + # 1. The prisma migrate reset command only affects the test database + # 2. Tests run against the test database, not the developer's local database + # 3. Any database operations during testing are isolated from development data + # + # Without this, if a developer has DATABASE_URL set in their environment pointing + # to their development database, running tests would wipe their local data! + test_env = os.environ.copy() - # Run the tests - result = subprocess.run(["pytest"] + sys.argv[1:], check=False) + # Load database configuration from .env file + dotenv_path = os.path.join(os.path.dirname(__file__), "../.env") + if os.path.exists(dotenv_path): + with open(dotenv_path) as f: + for line in f: + if line.strip() and not line.startswith("#"): + key, value = line.strip().split("=", 1) + os.environ[key] = value + + # Get database config from environment (now populated from .env) + db_user = os.getenv("POSTGRES_USER", "postgres") + db_pass = os.getenv("POSTGRES_PASSWORD", "postgres") + db_name = os.getenv("POSTGRES_DB", "postgres") + db_port = os.getenv("POSTGRES_PORT", "5432") + + # Construct the test database URL - this ensures we're always pointing to the test container + test_env["DATABASE_URL"] = ( + f"postgresql://{db_user}:{db_pass}@localhost:{db_port}/{db_name}" + ) + test_env["DIRECT_URL"] = test_env["DATABASE_URL"] + + test_env["DB_PORT"] = db_port + test_env["DB_NAME"] = db_name + test_env["DB_PASS"] = db_pass + test_env["DB_USER"] = db_user + + # Run Prisma migrations with test database + # First, reset the database to ensure clean state for tests + # This is safe because we've explicitly set DATABASE_URL to the test database above + subprocess.run( + ["prisma", "migrate", "reset", "--force", "--skip-seed"], + env=test_env, + check=False, + ) + # Then apply migrations to get the test database schema up to date + subprocess.run(["prisma", "migrate", "deploy"], env=test_env, check=True) + + # Run the tests with test database environment + # This ensures all database connections in the tests use the test database, + # not any database that might be configured in the developer's environment + result = subprocess.run(["pytest"] + sys.argv[1:], env=test_env, check=False) run_command(["docker", "compose", "-f", "docker-compose.test.yaml", "down"]) diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index 7cd0ed36f4b5..13c3d2c52982 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -1,12 +1,12 @@ -// THIS FILE IS AUTO-GENERATED, RUN `poetry run schema` TO UPDATE datasource db { - provider = "postgresql" - url = env("DATABASE_URL") + provider = "postgresql" + url = env("DATABASE_URL") + directUrl = env("DIRECT_URL") } generator client { provider = "prisma-client-py" - recursive_type_depth = 5 + recursive_type_depth = -1 interface = "asyncio" previewFeatures = ["views"] } @@ -15,32 +15,90 @@ generator client { model User { id String @id // This should match the Supabase user ID email String @unique + emailVerified Boolean @default(true) name String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt metadata Json @default("{}") integrations String @default("") stripeCustomerId String? + topUpConfig Json? + + maxEmailsPerDay Int @default(3) + notifyOnAgentRun Boolean @default(true) + notifyOnZeroBalance Boolean @default(true) + notifyOnLowBalance Boolean @default(true) + notifyOnBlockExecutionFailed Boolean @default(true) + notifyOnContinuousAgentError Boolean @default(true) + notifyOnDailySummary Boolean @default(true) + notifyOnWeeklySummary Boolean @default(true) + notifyOnMonthlySummary Boolean @default(true) + notifyOnAgentApproved Boolean @default(true) + notifyOnAgentRejected Boolean @default(true) + + timezone String @default("not-set") // Relations + AgentGraphs AgentGraph[] AgentGraphExecutions AgentGraphExecution[] AnalyticsDetails AnalyticsDetails[] AnalyticsMetrics AnalyticsMetrics[] - CreditTransaction CreditTransaction[] + CreditTransactions CreditTransaction[] + + AgentPresets AgentPreset[] + LibraryAgents LibraryAgent[] + + Profile Profile[] + UserOnboarding UserOnboarding? + StoreListings StoreListing[] + StoreListingReviews StoreListingReview[] + StoreVersionsReviewed StoreListingVersion[] + APIKeys APIKey[] + IntegrationWebhooks IntegrationWebhook[] + NotificationBatches UserNotificationBatch[] +} - AgentPreset AgentPreset[] - UserAgent UserAgent[] +enum OnboardingStep { + // Introductory onboarding (Library) + WELCOME + USAGE_REASON + INTEGRATIONS + AGENT_CHOICE + AGENT_NEW_RUN + AGENT_INPUT + CONGRATS + GET_RESULTS + RUN_AGENTS + // Marketplace + MARKETPLACE_VISIT + MARKETPLACE_ADD_AGENT + MARKETPLACE_RUN_AGENT + // Builder + BUILDER_OPEN + BUILDER_SAVE_AGENT + BUILDER_RUN_AGENT +} - Profile Profile[] - StoreListing StoreListing[] - StoreListingReview StoreListingReview[] - StoreListingSubmission StoreListingSubmission[] - APIKeys APIKey[] - IntegrationWebhooks IntegrationWebhook[] +model UserOnboarding { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt - @@index([id]) - @@index([email]) + completedSteps OnboardingStep[] @default([]) + notificationDot Boolean @default(true) + notified OnboardingStep[] @default([]) + rewardedFor OnboardingStep[] @default([]) + usageReason String? + integrations String[] @default([]) + otherIntegrations String? + selectedStoreListingVersionId String? + agentInput Json? + onboardingAgentExecutionId String? + agentRuns Int @default(0) + + userId String @unique + User User @relation(fields: [userId], references: [id], onDelete: Cascade) } // This model describes the Agent Graph/Flow (Multi Agent System). @@ -52,35 +110,41 @@ model AgentGraph { name String? description String? - isActive Boolean @default(true) - isTemplate Boolean @default(false) + + isActive Boolean @default(true) // Link to User model userId String // FIX: Do not cascade delete the agent when the user is deleted // This allows us to delete user data with deleting the agent which maybe in use by other users - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) - AgentNodes AgentNode[] - AgentGraphExecution AgentGraphExecution[] + forkedFromId String? + forkedFromVersion Int? + forkedFrom AgentGraph? @relation("AgentGraphForks", fields: [forkedFromId, forkedFromVersion], references: [id, version]) + forks AgentGraph[] @relation("AgentGraphForks") - AgentPreset AgentPreset[] - UserAgent UserAgent[] - StoreListing StoreListing[] - StoreListingVersion StoreListingVersion? + Nodes AgentNode[] + Executions AgentGraphExecution[] + + Presets AgentPreset[] + LibraryAgents LibraryAgent[] + StoreListings StoreListing[] + StoreListingVersions StoreListingVersion[] @@id(name: "graphVersionId", [id, version]) @@index([userId, isActive]) + @@index([forkedFromId, forkedFromVersion]) } -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// //////////////// USER SPECIFIC DATA //////////////////// -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// // An AgentPrest is an Agent + User Configuration of that agent. -// For example, if someone has created a weather agent and they want to set it up to +// For example, if someone has created a weather agent and they want to set it up to // Inform them of extreme weather warnings in Texas, the agent with the configuration to set it to // monitor texas, along with the cron setup or webhook tiggers, is an AgentPreset model AgentPreset { @@ -98,20 +162,72 @@ model AgentPreset { userId String User User @relation(fields: [userId], references: [id], onDelete: Cascade) - agentId String - agentVersion Int - Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version], onDelete: Cascade) + agentGraphId String + agentGraphVersion Int + AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version], onDelete: Restrict) + + InputPresets AgentNodeExecutionInputOutput[] @relation("AgentPresetsInputData") + Executions AgentGraphExecution[] + + // For webhook-triggered agents: reference to the webhook that triggers the agent + webhookId String? + Webhook IntegrationWebhook? @relation(fields: [webhookId], references: [id]) - InputPresets AgentNodeExecutionInputOutput[] @relation("AgentPresetsInputData") - UserAgents UserAgent[] - AgentExecution AgentGraphExecution[] + isDeleted Boolean @default(false) @@index([userId]) + @@index([agentGraphId, agentGraphVersion]) + @@index([webhookId]) +} + +enum NotificationType { + AGENT_RUN + ZERO_BALANCE + LOW_BALANCE + BLOCK_EXECUTION_FAILED + CONTINUOUS_AGENT_ERROR + DAILY_SUMMARY + WEEKLY_SUMMARY + MONTHLY_SUMMARY + REFUND_REQUEST + REFUND_PROCESSED + AGENT_APPROVED + AGENT_REJECTED +} + +model NotificationEvent { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + UserNotificationBatch UserNotificationBatch? @relation(fields: [userNotificationBatchId], references: [id]) + userNotificationBatchId String? + + type NotificationType + data Json + + @@index([userNotificationBatchId]) +} + +model UserNotificationBatch { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + + userId String + User User @relation(fields: [userId], references: [id], onDelete: Cascade) + + type NotificationType + + Notifications NotificationEvent[] + + // Each user can only have one batch of a notification type at a time + @@unique([userId, type]) } // For the library page -// It is a user controlled list of agents, that they will see in there library -model UserAgent { +// It is a user controlled list of agents, that they will see in their library +model LibraryAgent { id String @id @default(uuid()) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt @@ -119,26 +235,32 @@ model UserAgent { userId String User User @relation(fields: [userId], references: [id], onDelete: Cascade) - agentId String - agentVersion Int - Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version]) + imageUrl String? - agentPresetId String? - AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id]) + agentGraphId String + agentGraphVersion Int + AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version], onDelete: Restrict) + + creatorId String? + Creator Profile? @relation(fields: [creatorId], references: [id]) + + useGraphIsActiveVersion Boolean @default(false) isFavorite Boolean @default(false) isCreatedByUser Boolean @default(false) isArchived Boolean @default(false) isDeleted Boolean @default(false) - @@index([userId]) + @@unique([userId, agentGraphId, agentGraphVersion]) + @@index([agentGraphId, agentGraphVersion]) + @@index([creatorId]) } -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// //////// AGENT DEFINITION AND EXECUTION TABLES //////// -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// // This model describes a single node in the Agent Graph/Flow (Multi Agent System). model AgentNode { @@ -157,17 +279,15 @@ model AgentNode { // List of produced output, that the child node should be executed. Output AgentNodeLink[] @relation("AgentNodeSource") - // JSON serialized dict[str, str] containing predefined input values. - constantInput String @default("{}") + constantInput Json @default("{}") // For webhook-triggered blocks: reference to the webhook that triggers the node webhookId String? Webhook IntegrationWebhook? @relation(fields: [webhookId], references: [id]) - // JSON serialized dict[str, str] containing the node metadata. - metadata String @default("{}") + metadata Json @default("{}") - ExecutionHistory AgentNodeExecution[] + Executions AgentNodeExecution[] @@index([agentGraphId, agentGraphVersion]) @@index([agentBlockId]) @@ -207,7 +327,6 @@ model AgentBlock { // Prisma requires explicit back-references. ReferencedByAgentNode AgentNode[] - CreditTransaction CreditTransaction[] } // This model describes the status of an AgentGraphExecution or AgentNodeExecution. @@ -216,6 +335,7 @@ enum AgentExecutionStatus { QUEUED RUNNING COMPLETED + TERMINATED FAILED } @@ -226,24 +346,28 @@ model AgentGraphExecution { updatedAt DateTime? @updatedAt startedAt DateTime? + isDeleted Boolean @default(false) + executionStatus AgentExecutionStatus @default(COMPLETED) agentGraphId String agentGraphVersion Int @default(1) AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version], onDelete: Cascade) - AgentNodeExecutions AgentNodeExecution[] + NodeExecutions AgentNodeExecution[] - // Link to User model + // Link to User model -- Executed by this user userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) - stats String? // JSON serialized object - AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id]) + stats Json? agentPresetId String? + AgentPreset AgentPreset? @relation(fields: [agentPresetId], references: [id]) @@index([agentGraphId, agentGraphVersion]) @@index([userId]) + @@index([createdAt]) + @@index([agentPresetId]) } // This model describes the execution of an AgentNode. @@ -251,26 +375,26 @@ model AgentNodeExecution { id String @id @default(uuid()) agentGraphExecutionId String - AgentGraphExecution AgentGraphExecution @relation(fields: [agentGraphExecutionId], references: [id], onDelete: Cascade) + GraphExecution AgentGraphExecution @relation(fields: [agentGraphExecutionId], references: [id], onDelete: Cascade) agentNodeId String - AgentNode AgentNode @relation(fields: [agentNodeId], references: [id], onDelete: Cascade) + Node AgentNode @relation(fields: [agentNodeId], references: [id], onDelete: Cascade) Input AgentNodeExecutionInputOutput[] @relation("AgentNodeExecutionInput") Output AgentNodeExecutionInputOutput[] @relation("AgentNodeExecutionOutput") executionStatus AgentExecutionStatus @default(COMPLETED) - // Final JSON serialized input data for the node execution. - executionData String? + executionData Json? addedTime DateTime @default(now()) queuedTime DateTime? startedTime DateTime? endedTime DateTime? - stats String? // JSON serialized object + stats Json? - @@index([agentGraphExecutionId]) - @@index([agentNodeId]) + @@index([agentGraphExecutionId, agentNodeId, executionStatus]) + @@index([agentNodeId, executionStatus]) + @@index([addedTime, queuedTime]) } // This model describes the output of an AgentNodeExecution. @@ -278,7 +402,7 @@ model AgentNodeExecutionInputOutput { id String @id @default(uuid()) name String - data String + data Json? time DateTime @default(now()) // Prisma requires explicit back-references. @@ -293,6 +417,20 @@ model AgentNodeExecutionInputOutput { // Input and Output pin names are unique for each AgentNodeExecution. @@unique([referencedByInputExecId, referencedByOutputExecId, name]) @@index([referencedByOutputExecId]) + // Composite index for `upsert_execution_input`. + @@index([name, time]) + @@index([agentPresetId]) +} + +model AgentNodeExecutionKeyValueData { + userId String + key String + agentNodeExecutionId String + data Json? + createdAt DateTime @default(now()) + updatedAt DateTime? @updatedAt + + @@id([userId, key]) } // Webhook that is registered with a provider and propagates to one or more nodes @@ -302,7 +440,7 @@ model IntegrationWebhook { updatedAt DateTime? @updatedAt userId String - user User @relation(fields: [userId], references: [id], onDelete: Restrict) // Webhooks must be deregistered before deleting + User User @relation(fields: [userId], references: [id], onDelete: Restrict) // Webhooks must be deregistered before deleting provider String // e.g. 'github' credentialsId String // relation to the credentials that the webhook was created with @@ -314,9 +452,8 @@ model IntegrationWebhook { providerWebhookId String // Webhook ID assigned by the provider - AgentNodes AgentNode[] - - @@index([userId]) + AgentNodes AgentNode[] + AgentPresets AgentPreset[] } model AnalyticsDetails { @@ -329,7 +466,7 @@ model AnalyticsDetails { // Link to User model userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) // Analytics Categorical data used for filtering (indexable w and w/o userId) type String @@ -340,15 +477,14 @@ model AnalyticsDetails { // Indexable field for any count based analytical measures like page order clicking, tutorial step completion, etc. dataIndex String? - @@index([userId, type], name: "analyticsDetails") - @@index([type]) + @@index([userId, type]) } -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// ////////////// METRICS TRACKING TABLES //////////////// -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// model AnalyticsMetrics { id String @id @default(uuid()) createdAt DateTime @default(now()) @@ -364,34 +500,35 @@ model AnalyticsMetrics { // Link to User model userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) } +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////// ACCOUNTING AND CREDIT SYSTEM TABLES ////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// + enum CreditTransactionType { TOP_UP USAGE + GRANT + REFUND + CARD_CHECK } -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// -//////// ACCOUNTING AND CREDIT SYSTEM TABLES ////////// -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// model CreditTransaction { transactionKey String @default(uuid()) createdAt DateTime @default(now()) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - blockId String? - block AgentBlock? @relation(fields: [blockId], references: [id]) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) amount Int type CreditTransactionType + runningBalance Int? + isActive Boolean @default(true) metadata Json? @@ -399,11 +536,33 @@ model CreditTransaction { @@index([userId, createdAt]) } -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +enum CreditRefundRequestStatus { + PENDING + APPROVED + REJECTED +} + +model CreditRefundRequest { + id String @id @default(uuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + userId String + transactionKey String + + amount Int + reason String + result String? + status CreditRefundRequestStatus @default(PENDING) + + @@index([userId, transactionKey]) +} + +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// ////////////// Store TABLES /////////////////////////// -//////////////////////////////////////////////////////////// -//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// +//////////////////////////////////////////////////////////// model Profile { id String @id @default(uuid()) @@ -411,7 +570,7 @@ model Profile { updatedAt DateTime @default(now()) @updatedAt // Only 1 of user or group can be set. - // The user this profile belongs to, if any. + // The user this profile belongs to, if any. userId String? User User? @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -425,7 +584,8 @@ model Profile { isFeatured Boolean @default(false) - @@index([username]) + LibraryAgents LibraryAgent[] + @@index([userId]) } @@ -442,6 +602,16 @@ view Creator { agent_rating Float agent_runs Int is_featured Boolean + + // Materialized views used (refreshed every 15 minutes via pg_cron): + // - mv_agent_run_counts - Pre-aggregated agent execution counts by agentGraphId + // * idx_mv_agent_run_counts (UNIQUE on agentGraphId) - Primary lookup + // - mv_review_stats - Pre-aggregated review statistics (count, avg rating) by storeListingId + // * idx_mv_review_stats (UNIQUE on storeListingId) - Primary lookup + // * idx_mv_review_stats_rating (avg_rating DESC) - Sort by rating performance + // * idx_mv_review_stats_count (review_count DESC) - Sort by review count performance + // + // Query strategy: Uses CTEs to efficiently aggregate creator statistics leveraging materialized views } view StoreAgent { @@ -464,29 +634,68 @@ view StoreAgent { rating Float versions String[] - @@unique([creator_username, slug]) - @@index([creator_username]) - @@index([featured]) - @@index([categories]) - @@index([storeListingVersionId]) + // Materialized views used (refreshed every 15 minutes via pg_cron): + // - mv_agent_run_counts - Pre-aggregated agent execution counts by agentGraphId + // * idx_mv_agent_run_counts (UNIQUE on agentGraphId) - Primary lookup + // - mv_review_stats - Pre-aggregated review statistics (count, avg rating) by storeListingId + // * idx_mv_review_stats (UNIQUE on storeListingId) - Primary lookup + // * idx_mv_review_stats_rating (avg_rating DESC) - Sort by rating performance + // * idx_mv_review_stats_count (review_count DESC) - Sort by review count performance + // + // Query strategy: Uses CTE for version aggregation and joins with materialized views for performance } view StoreSubmission { - listing_id String @id - user_id String - slug String - name String - sub_heading String - description String - image_urls String[] - date_submitted DateTime - status SubmissionStatus - runs Int - rating Float - agent_id String - agent_version Int - - @@index([user_id]) + listing_id String @id + user_id String + slug String + name String + sub_heading String + description String + image_urls String[] + date_submitted DateTime + status SubmissionStatus + runs Int + rating Float + agent_id String + agent_version Int + store_listing_version_id String + reviewer_id String? + review_comments String? + internal_comments String? + reviewed_at DateTime? + changes_summary String? + video_url String? + categories String[] + + // Index or unique are not applied to views +} + +// Note: This is actually a MATERIALIZED VIEW in the database +// Refreshed automatically every 15 minutes via pg_cron (with fallback to manual refresh) +view mv_agent_run_counts { + agentGraphId String @unique + run_count Int + + // Pre-aggregated count of AgentGraphExecution records by agentGraphId + // Used by StoreAgent and Creator views for performance optimization + // Unique index created automatically on agentGraphId for fast lookups + // Refresh uses CONCURRENTLY to avoid blocking reads +} + +// Note: This is actually a MATERIALIZED VIEW in the database +// Refreshed automatically every 15 minutes via pg_cron (with fallback to manual refresh) +view mv_review_stats { + storeListingId String @unique + review_count Int + avg_rating Float + + // Pre-aggregated review statistics from StoreListingReview + // Includes count of reviews and average rating per StoreListing + // Only includes approved versions (submissionStatus = 'APPROVED') and non-deleted listings + // Used by StoreAgent view for performance optimization + // Unique index created automatically on storeListingId for fast lookups + // Refresh uses CONCURRENTLY to avoid blocking reads } model StoreListing { @@ -494,24 +703,34 @@ model StoreListing { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt - isDeleted Boolean @default(false) - // Not needed but makes lookups faster - isApproved Boolean @default(false) + isDeleted Boolean @default(false) + // Whether any version has been approved and is available for display + hasApprovedVersion Boolean @default(false) + + // URL-friendly identifier for this agent (moved from StoreListingVersion) + slug String - // The agent link here is only so we can do lookup on agentId, for the listing the StoreListingVersion is used. - agentId String - agentVersion Int - Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version], onDelete: Cascade) + // The currently active version that should be shown to users + activeVersionId String? @unique + ActiveVersion StoreListingVersion? @relation("ActiveVersion", fields: [activeVersionId], references: [id]) + + // The agent link here is only so we can do lookup on agentId + agentGraphId String + agentGraphVersion Int + AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version], onDelete: Cascade) owningUserId String OwningUser User @relation(fields: [owningUserId], references: [id]) - StoreListingVersions StoreListingVersion[] - StoreListingSubmission StoreListingSubmission[] + // Relations + Versions StoreListingVersion[] @relation("ListingVersions") - @@index([isApproved]) - @@index([agentId]) - @@index([owningUserId]) + // Unique index on agentId to ensure only one listing per agent, regardless of number of versions the agent has. + @@unique([agentGraphId]) + @@unique([owningUserId, slug]) + // Used in the view query + @@index([isDeleted, hasApprovedVersion]) + @@index([agentGraphId, agentGraphVersion]) } model StoreListingVersion { @@ -521,14 +740,11 @@ model StoreListingVersion { updatedAt DateTime @default(now()) @updatedAt // The agent and version to be listed on the store - agentId String - agentVersion Int - Agent AgentGraph @relation(fields: [agentId, agentVersion], references: [id, version]) + agentGraphId String + agentGraphVersion Int + AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version]) - // The detials for this version of the agent, this allows the author to update the details of the agent, - // But still allow using old versions of the agent with there original details. - // TODO: Create a database view that shows only the latest version of each store listing. - slug String + // Content fields name String subHeading String videoUrl String? @@ -538,21 +754,39 @@ model StoreListingVersion { isFeatured Boolean @default(false) - isDeleted Boolean @default(false) + isDeleted Boolean @default(false) // Old versions can be made unavailable by the author if desired - isAvailable Boolean @default(true) - // Not needed but makes lookups faster - isApproved Boolean @default(false) - StoreListing StoreListing? @relation(fields: [storeListingId], references: [id], onDelete: Cascade) - storeListingId String? - StoreListingSubmission StoreListingSubmission[] + isAvailable Boolean @default(true) + + // Version workflow state + submissionStatus SubmissionStatus @default(DRAFT) + submittedAt DateTime? + + // Relations + storeListingId String + StoreListing StoreListing @relation("ListingVersions", fields: [storeListingId], references: [id], onDelete: Cascade) - // Reviews are on a specific version, but then aggregated up to the listing. - // This allows us to provide a review filter to current version of the agent. - StoreListingReview StoreListingReview[] + // This version might be the active version for a listing + ActiveFor StoreListing? @relation("ActiveVersion") - @@unique([agentId, agentVersion]) - @@index([agentId, agentVersion, isApproved]) + // Submission history + changesSummary String? + + // Review information + reviewerId String? + Reviewer User? @relation(fields: [reviewerId], references: [id]) + internalComments String? // Private notes for admin use only + reviewComments String? // Comments visible to creator + reviewedAt DateTime? + + // Reviews for this specific version + Reviews StoreListingReview[] + + @@unique([storeListingId, version]) + @@index([storeListingId, submissionStatus, isAvailable]) + @@index([submissionStatus]) + @@index([reviewerId]) + @@index([agentGraphId, agentGraphVersion]) // Non-unique index for efficient lookups } model StoreListingReview { @@ -570,35 +804,14 @@ model StoreListingReview { comments String? @@unique([storeListingVersionId, reviewByUserId]) - @@index([storeListingVersionId]) + @@index([reviewByUserId]) } enum SubmissionStatus { - DAFT - PENDING - APPROVED - REJECTED -} - -model StoreListingSubmission { - id String @id @default(uuid()) - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - - storeListingId String - StoreListing StoreListing @relation(fields: [storeListingId], references: [id], onDelete: Cascade) - - storeListingVersionId String - StoreListingVersion StoreListingVersion @relation(fields: [storeListingVersionId], references: [id], onDelete: Cascade) - - reviewerId String - Reviewer User @relation(fields: [reviewerId], references: [id]) - - Status SubmissionStatus @default(PENDING) - reviewComments String? - - @@index([storeListingId]) - @@index([Status]) + DRAFT // Being prepared, not yet submitted + PENDING // Submitted, awaiting review + APPROVED // Reviewed and approved + REJECTED // Reviewed and rejected } enum APIKeyPermission { @@ -625,12 +838,9 @@ model APIKey { // Relation to user userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) + User User @relation(fields: [userId], references: [id], onDelete: Cascade) - @@index([key]) - @@index([prefix]) - @@index([userId]) - @@index([status]) + @@index([prefix, name]) @@index([userId, status]) } @@ -638,4 +848,4 @@ enum APIKeyStatus { ACTIVE REVOKED SUSPENDED -} \ No newline at end of file +} diff --git a/autogpt_platform/backend/snapshots/adm_add_cred_neg b/autogpt_platform/backend/snapshots/adm_add_cred_neg new file mode 100644 index 000000000000..a0b0fe279579 --- /dev/null +++ b/autogpt_platform/backend/snapshots/adm_add_cred_neg @@ -0,0 +1,4 @@ +{ + "new_balance": 200, + "transaction_key": "transaction-456-uuid" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/adm_add_cred_ok b/autogpt_platform/backend/snapshots/adm_add_cred_ok new file mode 100644 index 000000000000..7b5c4aa29bb4 --- /dev/null +++ b/autogpt_platform/backend/snapshots/adm_add_cred_ok @@ -0,0 +1,4 @@ +{ + "new_balance": 1500, + "transaction_key": "transaction-123-uuid" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/adm_usr_hist_empty b/autogpt_platform/backend/snapshots/adm_usr_hist_empty new file mode 100644 index 000000000000..4e9b333c37cb --- /dev/null +++ b/autogpt_platform/backend/snapshots/adm_usr_hist_empty @@ -0,0 +1,9 @@ +{ + "history": [], + "pagination": { + "current_page": 1, + "page_size": 20, + "total_items": 0, + "total_pages": 0 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/adm_usr_hist_filt b/autogpt_platform/backend/snapshots/adm_usr_hist_filt new file mode 100644 index 000000000000..aa5810250b06 --- /dev/null +++ b/autogpt_platform/backend/snapshots/adm_usr_hist_filt @@ -0,0 +1,28 @@ +{ + "history": [ + { + "admin_email": null, + "amount": 500, + "current_balance": 0, + "description": null, + "extra_data": null, + "reason": "Top up", + "running_balance": 0, + "transaction_key": "", + "transaction_time": "0001-01-01T00:00:00Z", + "transaction_type": "TOP_UP", + "usage_execution_id": null, + "usage_graph_id": null, + "usage_node_count": 0, + "usage_start_time": "9999-12-31T23:59:59.999999Z", + "user_email": "test@example.com", + "user_id": "user-3" + } + ], + "pagination": { + "current_page": 1, + "page_size": 10, + "total_items": 1, + "total_pages": 1 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/adm_usr_hist_ok b/autogpt_platform/backend/snapshots/adm_usr_hist_ok new file mode 100644 index 000000000000..7cc4d3634dd8 --- /dev/null +++ b/autogpt_platform/backend/snapshots/adm_usr_hist_ok @@ -0,0 +1,46 @@ +{ + "history": [ + { + "admin_email": null, + "amount": 1000, + "current_balance": 0, + "description": null, + "extra_data": null, + "reason": "Initial grant", + "running_balance": 0, + "transaction_key": "", + "transaction_time": "0001-01-01T00:00:00Z", + "transaction_type": "GRANT", + "usage_execution_id": null, + "usage_graph_id": null, + "usage_node_count": 0, + "usage_start_time": "9999-12-31T23:59:59.999999Z", + "user_email": "user1@example.com", + "user_id": "user-1" + }, + { + "admin_email": null, + "amount": -50, + "current_balance": 0, + "description": null, + "extra_data": null, + "reason": "Usage", + "running_balance": 0, + "transaction_key": "", + "transaction_time": "0001-01-01T00:00:00Z", + "transaction_type": "USAGE", + "usage_execution_id": null, + "usage_graph_id": null, + "usage_node_count": 0, + "usage_start_time": "9999-12-31T23:59:59.999999Z", + "user_email": "user2@example.com", + "user_id": "user-2" + } + ], + "pagination": { + "current_page": 1, + "page_size": 20, + "total_items": 2, + "total_pages": 1 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/admin_add_credits_success b/autogpt_platform/backend/snapshots/admin_add_credits_success new file mode 100644 index 000000000000..7b5c4aa29bb4 --- /dev/null +++ b/autogpt_platform/backend/snapshots/admin_add_credits_success @@ -0,0 +1,4 @@ +{ + "new_balance": 1500, + "transaction_key": "transaction-123-uuid" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/agt_details b/autogpt_platform/backend/snapshots/agt_details new file mode 100644 index 000000000000..f002d08775ad --- /dev/null +++ b/autogpt_platform/backend/snapshots/agt_details @@ -0,0 +1,27 @@ +{ + "store_listing_version_id": "test-version-id", + "slug": "test-agent", + "agent_name": "Test Agent", + "agent_video": "video.mp4", + "agent_image": [ + "image1.jpg", + "image2.jpg" + ], + "creator": "creator1", + "creator_avatar": "avatar1.jpg", + "sub_heading": "Test agent subheading", + "description": "Test agent description", + "categories": [ + "category1", + "category2" + ], + "runs": 100, + "rating": 4.5, + "versions": [ + "1.0.0", + "1.1.0" + ], + "last_updated": "2023-01-01T00:00:00", + "active_version_id": null, + "has_approved_version": false +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/agts_by_creator b/autogpt_platform/backend/snapshots/agts_by_creator new file mode 100644 index 000000000000..4d6dd1292032 --- /dev/null +++ b/autogpt_platform/backend/snapshots/agts_by_creator @@ -0,0 +1,21 @@ +{ + "agents": [ + { + "slug": "creator-agent", + "agent_name": "Creator Agent", + "agent_image": "agent.jpg", + "creator": "specific-creator", + "creator_avatar": "avatar.jpg", + "sub_heading": "Creator agent subheading", + "description": "Creator agent description", + "runs": 50, + "rating": 4.0 + } + ], + "pagination": { + "total_items": 1, + "total_pages": 1, + "current_page": 1, + "page_size": 20 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/agts_category b/autogpt_platform/backend/snapshots/agts_category new file mode 100644 index 000000000000..f65925ead343 --- /dev/null +++ b/autogpt_platform/backend/snapshots/agts_category @@ -0,0 +1,21 @@ +{ + "agents": [ + { + "slug": "category-agent", + "agent_name": "Category Agent", + "agent_image": "category.jpg", + "creator": "creator1", + "creator_avatar": "avatar1.jpg", + "sub_heading": "Category agent subheading", + "description": "Category agent description", + "runs": 60, + "rating": 4.1 + } + ], + "pagination": { + "total_items": 1, + "total_pages": 1, + "current_page": 1, + "page_size": 20 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/agts_pagination b/autogpt_platform/backend/snapshots/agts_pagination new file mode 100644 index 000000000000..82e7f5f9bf8a --- /dev/null +++ b/autogpt_platform/backend/snapshots/agts_pagination @@ -0,0 +1,65 @@ +{ + "agents": [ + { + "slug": "agent-0", + "agent_name": "Agent 0", + "agent_image": "agent0.jpg", + "creator": "creator1", + "creator_avatar": "avatar1.jpg", + "sub_heading": "Agent 0 subheading", + "description": "Agent 0 description", + "runs": 0, + "rating": 4.0 + }, + { + "slug": "agent-1", + "agent_name": "Agent 1", + "agent_image": "agent1.jpg", + "creator": "creator1", + "creator_avatar": "avatar1.jpg", + "sub_heading": "Agent 1 subheading", + "description": "Agent 1 description", + "runs": 10, + "rating": 4.0 + }, + { + "slug": "agent-2", + "agent_name": "Agent 2", + "agent_image": "agent2.jpg", + "creator": "creator1", + "creator_avatar": "avatar1.jpg", + "sub_heading": "Agent 2 subheading", + "description": "Agent 2 description", + "runs": 20, + "rating": 4.0 + }, + { + "slug": "agent-3", + "agent_name": "Agent 3", + "agent_image": "agent3.jpg", + "creator": "creator1", + "creator_avatar": "avatar1.jpg", + "sub_heading": "Agent 3 subheading", + "description": "Agent 3 description", + "runs": 30, + "rating": 4.0 + }, + { + "slug": "agent-4", + "agent_name": "Agent 4", + "agent_image": "agent4.jpg", + "creator": "creator1", + "creator_avatar": "avatar1.jpg", + "sub_heading": "Agent 4 subheading", + "description": "Agent 4 description", + "runs": 40, + "rating": 4.0 + } + ], + "pagination": { + "total_items": 15, + "total_pages": 3, + "current_page": 2, + "page_size": 5 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/agts_search b/autogpt_platform/backend/snapshots/agts_search new file mode 100644 index 000000000000..ca3f50458405 --- /dev/null +++ b/autogpt_platform/backend/snapshots/agts_search @@ -0,0 +1,21 @@ +{ + "agents": [ + { + "slug": "search-agent", + "agent_name": "Search Agent", + "agent_image": "search.jpg", + "creator": "creator1", + "creator_avatar": "avatar1.jpg", + "sub_heading": "Search agent subheading", + "description": "Specific search term description", + "runs": 75, + "rating": 4.2 + } + ], + "pagination": { + "total_items": 1, + "total_pages": 1, + "current_page": 1, + "page_size": 20 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/agts_sorted b/autogpt_platform/backend/snapshots/agts_sorted new file mode 100644 index 000000000000..cddead76a57a --- /dev/null +++ b/autogpt_platform/backend/snapshots/agts_sorted @@ -0,0 +1,21 @@ +{ + "agents": [ + { + "slug": "top-agent", + "agent_name": "Top Agent", + "agent_image": "top.jpg", + "creator": "creator1", + "creator_avatar": "avatar1.jpg", + "sub_heading": "Top agent subheading", + "description": "Top agent description", + "runs": 1000, + "rating": 5.0 + } + ], + "pagination": { + "total_items": 1, + "total_pages": 1, + "current_page": 1, + "page_size": 20 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_log_analytics_complex_data b/autogpt_platform/backend/snapshots/analytics_log_analytics_complex_data new file mode 100644 index 000000000000..4d671672a7fa --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_log_analytics_complex_data @@ -0,0 +1,30 @@ +{ + "analytics_id": "analytics-complex-uuid", + "logged_data": { + "agent_id": "agent_123", + "blocks_used": [ + { + "block_id": "llm_block", + "count": 3 + }, + { + "block_id": "http_block", + "count": 5 + }, + { + "block_id": "code_block", + "count": 2 + } + ], + "duration_ms": 3500, + "errors": [], + "execution_id": "exec_456", + "metadata": { + "environment": "production", + "trigger": "manual", + "user_tier": "premium" + }, + "nodes_executed": 15, + "status": "completed" + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_log_analytics_success b/autogpt_platform/backend/snapshots/analytics_log_analytics_success new file mode 100644 index 000000000000..a08eb0c89ca9 --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_log_analytics_success @@ -0,0 +1,3 @@ +{ + "analytics_id": "analytics-789-uuid" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_log_metric_success b/autogpt_platform/backend/snapshots/analytics_log_metric_success new file mode 100644 index 000000000000..a08f406588d8 --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_log_metric_success @@ -0,0 +1,3 @@ +{ + "metric_id": "metric-123-uuid" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_log_metric_success_improved b/autogpt_platform/backend/snapshots/analytics_log_metric_success_improved new file mode 100644 index 000000000000..a08f406588d8 --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_log_metric_success_improved @@ -0,0 +1,3 @@ +{ + "metric_id": "metric-123-uuid" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_log_metric_various_values b/autogpt_platform/backend/snapshots/analytics_log_metric_various_values new file mode 100644 index 000000000000..6edf88cecc5e --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_log_metric_various_values @@ -0,0 +1,3 @@ +{ + "metric_id": "metric-456-uuid" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_metric_float_precision b/autogpt_platform/backend/snapshots/analytics_metric_float_precision new file mode 100644 index 000000000000..8d3319fb8c89 --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_metric_float_precision @@ -0,0 +1,4 @@ +{ + "metric_id": "metric-float_precision-uuid", + "test_case": "float_precision" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_metric_integer_value b/autogpt_platform/backend/snapshots/analytics_metric_integer_value new file mode 100644 index 000000000000..113a602abfbe --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_metric_integer_value @@ -0,0 +1,4 @@ +{ + "metric_id": "metric-integer_value-uuid", + "test_case": "integer_value" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_metric_large_number b/autogpt_platform/backend/snapshots/analytics_metric_large_number new file mode 100644 index 000000000000..8ad9f60a6652 --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_metric_large_number @@ -0,0 +1,4 @@ +{ + "metric_id": "metric-large_number-uuid", + "test_case": "large_number" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_metric_negative_value b/autogpt_platform/backend/snapshots/analytics_metric_negative_value new file mode 100644 index 000000000000..427967ee0474 --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_metric_negative_value @@ -0,0 +1,4 @@ +{ + "metric_id": "metric-negative_value-uuid", + "test_case": "negative_value" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_metric_tiny_number b/autogpt_platform/backend/snapshots/analytics_metric_tiny_number new file mode 100644 index 000000000000..c7543df027c8 --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_metric_tiny_number @@ -0,0 +1,4 @@ +{ + "metric_id": "metric-tiny_number-uuid", + "test_case": "tiny_number" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/analytics_metric_zero_value b/autogpt_platform/backend/snapshots/analytics_metric_zero_value new file mode 100644 index 000000000000..482cbf0f6cc4 --- /dev/null +++ b/autogpt_platform/backend/snapshots/analytics_metric_zero_value @@ -0,0 +1,4 @@ +{ + "metric_id": "metric-zero_value-uuid", + "test_case": "zero_value" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/auth_email b/autogpt_platform/backend/snapshots/auth_email new file mode 100644 index 000000000000..178eb291ca89 --- /dev/null +++ b/autogpt_platform/backend/snapshots/auth_email @@ -0,0 +1,3 @@ +{ + "email": "newemail@example.com" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/auth_user b/autogpt_platform/backend/snapshots/auth_user new file mode 100644 index 000000000000..0fa7824e313e --- /dev/null +++ b/autogpt_platform/backend/snapshots/auth_user @@ -0,0 +1,5 @@ +{ + "email": "test@example.com", + "id": "test-user-id", + "name": "Test User" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/blks_all b/autogpt_platform/backend/snapshots/blks_all new file mode 100644 index 000000000000..714fa205580e --- /dev/null +++ b/autogpt_platform/backend/snapshots/blks_all @@ -0,0 +1,14 @@ +[ + { + "costs": [ + { + "cost": 10, + "type": "credit" + } + ], + "description": "A test block", + "disabled": false, + "id": "test-block", + "name": "Test Block" + } +] \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/blks_exec b/autogpt_platform/backend/snapshots/blks_exec new file mode 100644 index 000000000000..6992daaf625b --- /dev/null +++ b/autogpt_platform/backend/snapshots/blks_exec @@ -0,0 +1,12 @@ +{ + "output1": [ + { + "data": "result1" + } + ], + "output2": [ + { + "data": "result2" + } + ] +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/creator_details b/autogpt_platform/backend/snapshots/creator_details new file mode 100644 index 000000000000..0b4e4812c237 --- /dev/null +++ b/autogpt_platform/backend/snapshots/creator_details @@ -0,0 +1,16 @@ +{ + "name": "Test User", + "username": "creator1", + "description": "Test creator description", + "links": [ + "link1.com", + "link2.com" + ], + "avatar_url": "avatar.jpg", + "agent_rating": 4.8, + "agent_runs": 1000, + "top_categories": [ + "category1", + "category2" + ] +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/creators_pagination b/autogpt_platform/backend/snapshots/creators_pagination new file mode 100644 index 000000000000..5076160d5dbf --- /dev/null +++ b/autogpt_platform/backend/snapshots/creators_pagination @@ -0,0 +1,60 @@ +{ + "creators": [ + { + "name": "Creator 0", + "username": "creator0", + "description": "Creator 0 description", + "avatar_url": "avatar0.jpg", + "num_agents": 1, + "agent_rating": 4.5, + "agent_runs": 100, + "is_featured": false + }, + { + "name": "Creator 1", + "username": "creator1", + "description": "Creator 1 description", + "avatar_url": "avatar1.jpg", + "num_agents": 1, + "agent_rating": 4.5, + "agent_runs": 100, + "is_featured": false + }, + { + "name": "Creator 2", + "username": "creator2", + "description": "Creator 2 description", + "avatar_url": "avatar2.jpg", + "num_agents": 1, + "agent_rating": 4.5, + "agent_runs": 100, + "is_featured": false + }, + { + "name": "Creator 3", + "username": "creator3", + "description": "Creator 3 description", + "avatar_url": "avatar3.jpg", + "num_agents": 1, + "agent_rating": 4.5, + "agent_runs": 100, + "is_featured": false + }, + { + "name": "Creator 4", + "username": "creator4", + "description": "Creator 4 description", + "avatar_url": "avatar4.jpg", + "num_agents": 1, + "agent_rating": 4.5, + "agent_runs": 100, + "is_featured": false + } + ], + "pagination": { + "total_items": 15, + "total_pages": 3, + "current_page": 2, + "page_size": 5 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/cred_bal b/autogpt_platform/backend/snapshots/cred_bal new file mode 100644 index 000000000000..b31d00b2dea4 --- /dev/null +++ b/autogpt_platform/backend/snapshots/cred_bal @@ -0,0 +1,3 @@ +{ + "credits": 1000 +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/cred_topup_cfg b/autogpt_platform/backend/snapshots/cred_topup_cfg new file mode 100644 index 000000000000..0158ac652d7d --- /dev/null +++ b/autogpt_platform/backend/snapshots/cred_topup_cfg @@ -0,0 +1,4 @@ +{ + "amount": 500, + "threshold": 100 +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/cred_topup_req b/autogpt_platform/backend/snapshots/cred_topup_req new file mode 100644 index 000000000000..91f4d4d348b3 --- /dev/null +++ b/autogpt_platform/backend/snapshots/cred_topup_req @@ -0,0 +1,3 @@ +{ + "checkout_url": "https://checkout.example.com/session123" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/def_agts b/autogpt_platform/backend/snapshots/def_agts new file mode 100644 index 000000000000..7ff57fae7f77 --- /dev/null +++ b/autogpt_platform/backend/snapshots/def_agts @@ -0,0 +1,9 @@ +{ + "agents": [], + "pagination": { + "total_items": 0, + "total_pages": 0, + "current_page": 0, + "page_size": 10 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/def_creators b/autogpt_platform/backend/snapshots/def_creators new file mode 100644 index 000000000000..7cde4005185e --- /dev/null +++ b/autogpt_platform/backend/snapshots/def_creators @@ -0,0 +1,9 @@ +{ + "creators": [], + "pagination": { + "total_items": 0, + "total_pages": 0, + "current_page": 0, + "page_size": 10 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/feat_agts b/autogpt_platform/backend/snapshots/feat_agts new file mode 100644 index 000000000000..d57996a7683d --- /dev/null +++ b/autogpt_platform/backend/snapshots/feat_agts @@ -0,0 +1,21 @@ +{ + "agents": [ + { + "slug": "featured-agent", + "agent_name": "Featured Agent", + "agent_image": "featured.jpg", + "creator": "creator1", + "creator_avatar": "avatar1.jpg", + "sub_heading": "Featured agent subheading", + "description": "Featured agent description", + "runs": 100, + "rating": 4.5 + } + ], + "pagination": { + "total_items": 1, + "total_pages": 1, + "current_page": 1, + "page_size": 20 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/grph_in_schm b/autogpt_platform/backend/snapshots/grph_in_schm new file mode 100644 index 000000000000..b9fa6f8d91a9 --- /dev/null +++ b/autogpt_platform/backend/snapshots/grph_in_schm @@ -0,0 +1,20 @@ +{ + "properties": { + "in_key_a": { + "advanced": true, + "default": "A", + "secret": false, + "title": "Key A" + }, + "in_key_b": { + "advanced": false, + "secret": false, + "title": "in_key_b" + } + }, + "required": [ + "in_key_b" + ], + "title": "ExpectedInputSchema", + "type": "object" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/grph_out_schm b/autogpt_platform/backend/snapshots/grph_out_schm new file mode 100644 index 000000000000..8395d3fbb879 --- /dev/null +++ b/autogpt_platform/backend/snapshots/grph_out_schm @@ -0,0 +1,15 @@ +{ + "properties": { + "out_key": { + "advanced": false, + "description": "This is an output key", + "secret": false, + "title": "out_key" + } + }, + "required": [ + "out_key" + ], + "title": "ExpectedOutputSchema", + "type": "object" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/grph_single b/autogpt_platform/backend/snapshots/grph_single new file mode 100644 index 000000000000..0d5dee072ce0 --- /dev/null +++ b/autogpt_platform/backend/snapshots/grph_single @@ -0,0 +1,29 @@ +{ + "credentials_input_schema": { + "properties": {}, + "title": "TestGraphCredentialsInputSchema", + "type": "object" + }, + "description": "A test graph", + "forked_from_id": null, + "forked_from_version": null, + "has_external_trigger": false, + "id": "graph-123", + "input_schema": { + "properties": {}, + "required": [], + "type": "object" + }, + "is_active": true, + "links": [], + "name": "Test Graph", + "nodes": [], + "output_schema": { + "properties": {}, + "required": [], + "type": "object" + }, + "sub_graphs": [], + "user_id": "test-user-id", + "version": 1 +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/grph_struct b/autogpt_platform/backend/snapshots/grph_struct new file mode 100644 index 000000000000..b0daad42f7f1 --- /dev/null +++ b/autogpt_platform/backend/snapshots/grph_struct @@ -0,0 +1,17 @@ +{ + "description": "Test graph", + "link_structure": [ + { + "sink_name": "name", + "source_name": "output" + } + ], + "links_count": 1, + "name": "TestGraph", + "node_blocks": [ + "1ff065e9-88e8-4358-9d82-8dc91f622ba9", + "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b", + "1ff065e9-88e8-4358-9d82-8dc91f622ba9" + ], + "nodes_count": 3 +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/grphs_all b/autogpt_platform/backend/snapshots/grphs_all new file mode 100644 index 000000000000..e9abae0c2521 --- /dev/null +++ b/autogpt_platform/backend/snapshots/grphs_all @@ -0,0 +1,29 @@ +[ + { + "credentials_input_schema": { + "properties": {}, + "title": "TestGraphCredentialsInputSchema", + "type": "object" + }, + "description": "A test graph", + "forked_from_id": null, + "forked_from_version": null, + "has_external_trigger": false, + "id": "graph-123", + "input_schema": { + "properties": {}, + "required": [], + "type": "object" + }, + "is_active": true, + "name": "Test Graph", + "output_schema": { + "properties": {}, + "required": [], + "type": "object" + }, + "sub_graphs": [], + "user_id": "test-user-id", + "version": 1 + } +] \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/grphs_del b/autogpt_platform/backend/snapshots/grphs_del new file mode 100644 index 000000000000..e3002615b327 --- /dev/null +++ b/autogpt_platform/backend/snapshots/grphs_del @@ -0,0 +1,3 @@ +{ + "version_counts": 3 +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/lib_agts_search b/autogpt_platform/backend/snapshots/lib_agts_search new file mode 100644 index 000000000000..f19d42896ba0 --- /dev/null +++ b/autogpt_platform/backend/snapshots/lib_agts_search @@ -0,0 +1,68 @@ +{ + "agents": [ + { + "id": "test-agent-1", + "graph_id": "test-agent-1", + "graph_version": 1, + "image_url": null, + "creator_name": "Test Creator", + "creator_image_url": "", + "status": "COMPLETED", + "updated_at": "2023-01-01T00:00:00", + "name": "Test Agent 1", + "description": "Test Description 1", + "input_schema": { + "type": "object", + "properties": {} + }, + "output_schema": { + "type": "object", + "properties": {} + }, + "credentials_input_schema": { + "type": "object", + "properties": {} + }, + "has_external_trigger": false, + "trigger_setup_info": null, + "new_output": false, + "can_access_graph": true, + "is_latest_version": true + }, + { + "id": "test-agent-2", + "graph_id": "test-agent-2", + "graph_version": 1, + "image_url": null, + "creator_name": "Test Creator", + "creator_image_url": "", + "status": "COMPLETED", + "updated_at": "2023-01-01T00:00:00", + "name": "Test Agent 2", + "description": "Test Description 2", + "input_schema": { + "type": "object", + "properties": {} + }, + "output_schema": { + "type": "object", + "properties": {} + }, + "credentials_input_schema": { + "type": "object", + "properties": {} + }, + "has_external_trigger": false, + "trigger_setup_info": null, + "new_output": false, + "can_access_graph": false, + "is_latest_version": true + } + ], + "pagination": { + "total_items": 2, + "total_pages": 1, + "current_page": 1, + "page_size": 50 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/log_anlyt_cplx b/autogpt_platform/backend/snapshots/log_anlyt_cplx new file mode 100644 index 000000000000..4d671672a7fa --- /dev/null +++ b/autogpt_platform/backend/snapshots/log_anlyt_cplx @@ -0,0 +1,30 @@ +{ + "analytics_id": "analytics-complex-uuid", + "logged_data": { + "agent_id": "agent_123", + "blocks_used": [ + { + "block_id": "llm_block", + "count": 3 + }, + { + "block_id": "http_block", + "count": 5 + }, + { + "block_id": "code_block", + "count": 2 + } + ], + "duration_ms": 3500, + "errors": [], + "execution_id": "exec_456", + "metadata": { + "environment": "production", + "trigger": "manual", + "user_tier": "premium" + }, + "nodes_executed": 15, + "status": "completed" + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/log_anlyt_ok b/autogpt_platform/backend/snapshots/log_anlyt_ok new file mode 100644 index 000000000000..a08eb0c89ca9 --- /dev/null +++ b/autogpt_platform/backend/snapshots/log_anlyt_ok @@ -0,0 +1,3 @@ +{ + "analytics_id": "analytics-789-uuid" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/log_metric_ok b/autogpt_platform/backend/snapshots/log_metric_ok new file mode 100644 index 000000000000..a08f406588d8 --- /dev/null +++ b/autogpt_platform/backend/snapshots/log_metric_ok @@ -0,0 +1,3 @@ +{ + "metric_id": "metric-123-uuid" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/log_metric_vals b/autogpt_platform/backend/snapshots/log_metric_vals new file mode 100644 index 000000000000..6edf88cecc5e --- /dev/null +++ b/autogpt_platform/backend/snapshots/log_metric_vals @@ -0,0 +1,3 @@ +{ + "metric_id": "metric-456-uuid" +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/otto_empty b/autogpt_platform/backend/snapshots/otto_empty new file mode 100644 index 000000000000..3ddc69e673f3 --- /dev/null +++ b/autogpt_platform/backend/snapshots/otto_empty @@ -0,0 +1,5 @@ +{ + "answer": "Welcome! How can I help you?", + "documents": [], + "success": true +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/otto_err b/autogpt_platform/backend/snapshots/otto_err new file mode 100644 index 000000000000..e451389529eb --- /dev/null +++ b/autogpt_platform/backend/snapshots/otto_err @@ -0,0 +1,5 @@ +{ + "answer": "An error occurred while processing your request.", + "documents": [], + "success": false +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/otto_grph b/autogpt_platform/backend/snapshots/otto_grph new file mode 100644 index 000000000000..67f8b7dc90a8 --- /dev/null +++ b/autogpt_platform/backend/snapshots/otto_grph @@ -0,0 +1,10 @@ +{ + "answer": "Here's information about your graph.", + "documents": [ + { + "relevance_score": 0.92, + "url": "https://example.com/graph-doc" + } + ], + "success": true +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/otto_ok b/autogpt_platform/backend/snapshots/otto_ok new file mode 100644 index 000000000000..f278f904f989 --- /dev/null +++ b/autogpt_platform/backend/snapshots/otto_ok @@ -0,0 +1,14 @@ +{ + "answer": "This is Otto's response to your query.", + "documents": [ + { + "relevance_score": 0.95, + "url": "https://example.com/doc1" + }, + { + "relevance_score": 0.87, + "url": "https://example.com/doc2" + } + ], + "success": true +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/sub b/autogpt_platform/backend/snapshots/sub new file mode 100644 index 000000000000..da65ce90a4b2 --- /dev/null +++ b/autogpt_platform/backend/snapshots/sub @@ -0,0 +1,7 @@ +{ + "channel": "3e53486c-cf57-477e-ba2a-cb02dc828e1a|graph_exec#test-graph-exec-1", + "data": null, + "error": null, + "method": "subscribe_graph_execution", + "success": true +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/sub_pagination b/autogpt_platform/backend/snapshots/sub_pagination new file mode 100644 index 000000000000..7bc1cb9b5725 --- /dev/null +++ b/autogpt_platform/backend/snapshots/sub_pagination @@ -0,0 +1,9 @@ +{ + "submissions": [], + "pagination": { + "total_items": 10, + "total_pages": 2, + "current_page": 2, + "page_size": 5 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/sub_success b/autogpt_platform/backend/snapshots/sub_success new file mode 100644 index 000000000000..5f46f4a43444 --- /dev/null +++ b/autogpt_platform/backend/snapshots/sub_success @@ -0,0 +1,36 @@ +{ + "submissions": [ + { + "agent_id": "test-agent-id", + "agent_version": 1, + "name": "Test Agent", + "sub_heading": "Test agent subheading", + "slug": "test-agent", + "description": "Test agent description", + "image_urls": [ + "test.jpg" + ], + "date_submitted": "2023-01-01T00:00:00", + "status": "APPROVED", + "runs": 50, + "rating": 4.2, + "store_listing_version_id": null, + "version": null, + "reviewer_id": null, + "review_comments": null, + "internal_comments": null, + "reviewed_at": null, + "changes_summary": null, + "video_url": "test.mp4", + "categories": [ + "test-category" + ] + } + ], + "pagination": { + "total_items": 1, + "total_pages": 1, + "current_page": 1, + "page_size": 20 + } +} \ No newline at end of file diff --git a/autogpt_platform/backend/snapshots/unsub b/autogpt_platform/backend/snapshots/unsub new file mode 100644 index 000000000000..24ad944c3f72 --- /dev/null +++ b/autogpt_platform/backend/snapshots/unsub @@ -0,0 +1,7 @@ +{ + "channel": "3e53486c-cf57-477e-ba2a-cb02dc828e1a|graph_exec#test-graph-exec-1", + "data": null, + "error": null, + "method": "unsubscribe", + "success": true +} \ No newline at end of file diff --git a/autogpt_platform/backend/test/__init__.py b/autogpt_platform/backend/test/__init__.py index d10438719da5..67d696d6d016 100644 --- a/autogpt_platform/backend/test/__init__.py +++ b/autogpt_platform/backend/test/__init__.py @@ -1,3 +1 @@ -import os - -os.environ["ENABLE_AUTH"] = "false" +# This file makes the test directory a Python module diff --git a/autogpt_platform/backend/test/block/test_block.py b/autogpt_platform/backend/test/block/test_block.py deleted file mode 100644 index 48d2616f613e..000000000000 --- a/autogpt_platform/backend/test/block/test_block.py +++ /dev/null @@ -1,11 +0,0 @@ -from typing import Type - -import pytest - -from backend.data.block import Block, get_blocks -from backend.util.test import execute_block_test - - -@pytest.mark.parametrize("block", get_blocks().values(), ids=lambda b: b.name) -def test_available_blocks(block: Type[Block]): - execute_block_test(block()) diff --git a/autogpt_platform/backend/test/blocks/test_gmail.py b/autogpt_platform/backend/test/blocks/test_gmail.py new file mode 100644 index 000000000000..5c086c6e0b0e --- /dev/null +++ b/autogpt_platform/backend/test/blocks/test_gmail.py @@ -0,0 +1,231 @@ +import base64 +from unittest.mock import Mock, patch + +import pytest + +from backend.blocks.google.gmail import GmailReadBlock + + +class TestGmailReadBlock: + """Test cases for GmailReadBlock email body parsing functionality.""" + + def setup_method(self): + """Set up test fixtures.""" + self.gmail_block = GmailReadBlock() + self.mock_service = Mock() + + def _encode_base64(self, text: str) -> str: + """Helper to encode text as base64 URL-safe.""" + return base64.urlsafe_b64encode(text.encode("utf-8")).decode("utf-8") + + @pytest.mark.asyncio + async def test_single_part_text_plain(self): + """Test parsing single-part text/plain email.""" + body_text = "This is a plain text email body." + msg = { + "id": "test_msg_1", + "payload": { + "mimeType": "text/plain", + "body": {"data": self._encode_base64(body_text)}, + }, + } + + result = await self.gmail_block._get_email_body(msg, self.mock_service) + assert result == body_text + + @pytest.mark.asyncio + async def test_multipart_alternative_plain_and_html(self): + """Test parsing multipart/alternative with both plain and HTML parts.""" + plain_text = "This is the plain text version." + html_text = "

This is the HTML version.

" + + msg = { + "id": "test_msg_2", + "payload": { + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": {"data": self._encode_base64(plain_text)}, + }, + { + "mimeType": "text/html", + "body": {"data": self._encode_base64(html_text)}, + }, + ], + }, + } + + result = await self.gmail_block._get_email_body(msg, self.mock_service) + # Should prefer plain text over HTML + assert result == plain_text + + @pytest.mark.asyncio + async def test_html_only_email(self): + """Test parsing HTML-only email with conversion to plain text.""" + html_text = ( + "

Hello World

This is HTML content.

" + ) + + msg = { + "id": "test_msg_3", + "payload": { + "mimeType": "text/html", + "body": {"data": self._encode_base64(html_text)}, + }, + } + + with patch("html2text.HTML2Text") as mock_html2text: + mock_converter = Mock() + mock_converter.handle.return_value = "Hello World\n\nThis is HTML content." + mock_html2text.return_value = mock_converter + + result = await self.gmail_block._get_email_body(msg, self.mock_service) + assert "Hello World" in result + assert "This is HTML content" in result + + @pytest.mark.asyncio + async def test_html_fallback_when_html2text_unavailable(self): + """Test fallback to raw HTML when html2text is not available.""" + html_text = "

HTML content

" + + msg = { + "id": "test_msg_4", + "payload": { + "mimeType": "text/html", + "body": {"data": self._encode_base64(html_text)}, + }, + } + + with patch("html2text.HTML2Text", side_effect=ImportError): + result = await self.gmail_block._get_email_body(msg, self.mock_service) + assert result == html_text + + @pytest.mark.asyncio + async def test_nested_multipart_structure(self): + """Test parsing deeply nested multipart structure.""" + plain_text = "Nested plain text content." + + msg = { + "id": "test_msg_5", + "payload": { + "mimeType": "multipart/mixed", + "parts": [ + { + "mimeType": "multipart/alternative", + "parts": [ + { + "mimeType": "text/plain", + "body": {"data": self._encode_base64(plain_text)}, + }, + ], + }, + ], + }, + } + + result = await self.gmail_block._get_email_body(msg, self.mock_service) + assert result == plain_text + + @pytest.mark.asyncio + async def test_attachment_body_content(self): + """Test parsing email where body is stored as attachment.""" + attachment_data = self._encode_base64("Body content from attachment.") + + msg = { + "id": "test_msg_6", + "payload": { + "mimeType": "text/plain", + "body": {"attachmentId": "attachment_123"}, + }, + } + + # Mock the attachment download + self.mock_service.users().messages().attachments().get().execute.return_value = { + "data": attachment_data + } + + result = await self.gmail_block._get_email_body(msg, self.mock_service) + assert result == "Body content from attachment." + + @pytest.mark.asyncio + async def test_no_readable_body(self): + """Test email with no readable body content.""" + msg = { + "id": "test_msg_7", + "payload": { + "mimeType": "application/octet-stream", + "body": {}, + }, + } + + result = await self.gmail_block._get_email_body(msg, self.mock_service) + assert result == "This email does not contain a readable body." + + @pytest.mark.asyncio + async def test_base64_padding_handling(self): + """Test proper handling of base64 data with missing padding.""" + # Create base64 data with missing padding + text = "Test content" + encoded = base64.urlsafe_b64encode(text.encode("utf-8")).decode("utf-8") + # Remove padding + encoded_no_padding = encoded.rstrip("=") + + result = self.gmail_block._decode_base64(encoded_no_padding) + assert result == text + + @pytest.mark.asyncio + async def test_recursion_depth_limit(self): + """Test that recursion depth is properly limited.""" + + # Create a deeply nested structure that would exceed the limit + def create_nested_part(depth): + if depth > 15: # Exceed the limit of 10 + return { + "mimeType": "text/plain", + "body": {"data": self._encode_base64("Deep content")}, + } + return { + "mimeType": "multipart/mixed", + "parts": [create_nested_part(depth + 1)], + } + + msg = { + "id": "test_msg_8", + "payload": create_nested_part(0), + } + + result = await self.gmail_block._get_email_body(msg, self.mock_service) + # Should return fallback message due to depth limit + assert result == "This email does not contain a readable body." + + @pytest.mark.asyncio + async def test_malformed_base64_handling(self): + """Test handling of malformed base64 data.""" + result = self.gmail_block._decode_base64("invalid_base64_data!!!") + assert result is None + + @pytest.mark.asyncio + async def test_empty_data_handling(self): + """Test handling of empty or None data.""" + assert self.gmail_block._decode_base64("") is None + assert self.gmail_block._decode_base64(None) is None + + @pytest.mark.asyncio + async def test_attachment_download_failure(self): + """Test handling of attachment download failure.""" + msg = { + "id": "test_msg_9", + "payload": { + "mimeType": "text/plain", + "body": {"attachmentId": "invalid_attachment"}, + }, + } + + # Mock attachment download failure + self.mock_service.users().messages().attachments().get().execute.side_effect = ( + Exception("Download failed") + ) + + result = await self.gmail_block._get_email_body(msg, self.mock_service) + assert result == "This email does not contain a readable body." diff --git a/autogpt_platform/backend/test/conftest.py b/autogpt_platform/backend/test/conftest.py deleted file mode 100644 index d107a0b322ef..000000000000 --- a/autogpt_platform/backend/test/conftest.py +++ /dev/null @@ -1,48 +0,0 @@ -import logging - -import pytest - -from backend.util.test import SpinTestServer - -# NOTE: You can run tests like with the --log-cli-level=INFO to see the logs -# Set up logging -logger = logging.getLogger(__name__) - -# Create console handler with formatting -ch = logging.StreamHandler() -ch.setLevel(logging.INFO) -formatter = logging.Formatter("%(asctime)s - %(name)s - %(levelname)s - %(message)s") -ch.setFormatter(formatter) -logger.addHandler(ch) - - -@pytest.fixture(scope="session") -async def server(): - async with SpinTestServer() as server: - yield server - - -@pytest.fixture(scope="session", autouse=True) -async def graph_cleanup(server): - created_graph_ids = [] - original_create_graph = server.agent_server.test_create_graph - - async def create_graph_wrapper(*args, **kwargs): - created_graph = await original_create_graph(*args, **kwargs) - # Extract user_id correctly - user_id = kwargs.get("user_id", args[2] if len(args) > 2 else None) - created_graph_ids.append((created_graph.id, user_id)) - return created_graph - - try: - server.agent_server.test_create_graph = create_graph_wrapper - yield # This runs the test function - finally: - server.agent_server.test_create_graph = original_create_graph - - # Delete the created graphs and assert they were deleted - for graph_id, user_id in created_graph_ids: - if user_id: - resp = await server.agent_server.test_delete_graph(graph_id, user_id) - num_deleted = resp["version_counts"] - assert num_deleted > 0, f"Graph {graph_id} was not deleted." diff --git a/autogpt_platform/backend/test/data/test_credit.py b/autogpt_platform/backend/test/data/test_credit.py deleted file mode 100644 index 41e6a697f92a..000000000000 --- a/autogpt_platform/backend/test/data/test_credit.py +++ /dev/null @@ -1,98 +0,0 @@ -from datetime import datetime - -import pytest -from prisma.models import CreditTransaction - -from backend.blocks.llm import AITextGeneratorBlock -from backend.data.credit import UserCredit -from backend.data.user import DEFAULT_USER_ID -from backend.integrations.credentials_store import openai_credentials -from backend.util.test import SpinTestServer - -REFILL_VALUE = 1000 -user_credit = UserCredit(REFILL_VALUE) - - -@pytest.mark.asyncio(scope="session") -async def test_block_credit_usage(server: SpinTestServer): - current_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) - - spending_amount_1 = await user_credit.spend_credits( - DEFAULT_USER_ID, - current_credit, - AITextGeneratorBlock().id, - { - "model": "gpt-4-turbo", - "credentials": { - "id": openai_credentials.id, - "provider": openai_credentials.provider, - "type": openai_credentials.type, - }, - }, - 0.0, - 0.0, - validate_balance=False, - ) - assert spending_amount_1 > 0 - - spending_amount_2 = await user_credit.spend_credits( - DEFAULT_USER_ID, - current_credit, - AITextGeneratorBlock().id, - {"model": "gpt-4-turbo", "api_key": "owned_api_key"}, - 0.0, - 0.0, - validate_balance=False, - ) - assert spending_amount_2 == 0 - - new_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) - assert new_credit == current_credit - spending_amount_1 - spending_amount_2 - - -@pytest.mark.asyncio(scope="session") -async def test_block_credit_top_up(server: SpinTestServer): - current_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) - - await user_credit.top_up_credits(DEFAULT_USER_ID, 100) - - new_credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) - assert new_credit == current_credit + 100 - - -@pytest.mark.asyncio(scope="session") -async def test_block_credit_reset(server: SpinTestServer): - month1 = datetime(2022, 1, 15) - month2 = datetime(2022, 2, 15) - - user_credit.time_now = lambda: month2 - month2credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) - - # Month 1 result should only affect month 1 - user_credit.time_now = lambda: month1 - month1credit = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) - await user_credit.top_up_credits(DEFAULT_USER_ID, 100) - assert await user_credit.get_or_refill_credit(DEFAULT_USER_ID) == month1credit + 100 - - # Month 2 balance is unaffected - user_credit.time_now = lambda: month2 - assert await user_credit.get_or_refill_credit(DEFAULT_USER_ID) == month2credit - - -@pytest.mark.asyncio(scope="session") -async def test_credit_refill(server: SpinTestServer): - # Clear all transactions within the month - await CreditTransaction.prisma().update_many( - where={ - "userId": DEFAULT_USER_ID, - "createdAt": { - "gte": datetime(2022, 2, 1), - "lt": datetime(2022, 3, 1), - }, - }, - data={"isActive": False}, - ) - user_credit.time_now = lambda: datetime(2022, 2, 15) - - balance = await user_credit.get_or_refill_credit(DEFAULT_USER_ID) - assert balance == REFILL_VALUE diff --git a/autogpt_platform/backend/test/data/test_graph.py b/autogpt_platform/backend/test/data/test_graph.py deleted file mode 100644 index 050e20fdc04b..000000000000 --- a/autogpt_platform/backend/test/data/test_graph.py +++ /dev/null @@ -1,157 +0,0 @@ -from typing import Any -from uuid import UUID - -import pytest - -from backend.blocks.basic import AgentInputBlock, AgentOutputBlock, StoreValueBlock -from backend.data.block import BlockSchema -from backend.data.graph import Graph, Link, Node -from backend.data.model import SchemaField -from backend.data.user import DEFAULT_USER_ID -from backend.server.model import CreateGraph -from backend.util.test import SpinTestServer - - -@pytest.mark.asyncio(scope="session") -async def test_graph_creation(server: SpinTestServer): - """ - Test the creation of a graph with nodes and links. - - This test ensures that: - 1. A graph can be successfully created with valid connections. - 2. The created graph has the correct structure and properties. - - Args: - server (SpinTestServer): The test server instance. - """ - value_block = StoreValueBlock().id - input_block = AgentInputBlock().id - - graph = Graph( - id="test_graph", - name="TestGraph", - description="Test graph", - nodes=[ - Node(id="node_1", block_id=value_block), - Node(id="node_2", block_id=input_block, input_default={"name": "input"}), - Node(id="node_3", block_id=value_block), - ], - links=[ - Link( - source_id="node_1", - sink_id="node_2", - source_name="output", - sink_name="name", - ), - ], - ) - create_graph = CreateGraph(graph=graph) - created_graph = await server.agent_server.test_create_graph( - create_graph, DEFAULT_USER_ID - ) - - assert UUID(created_graph.id) - assert created_graph.name == "TestGraph" - - assert len(created_graph.nodes) == 3 - assert UUID(created_graph.nodes[0].id) - assert UUID(created_graph.nodes[1].id) - assert UUID(created_graph.nodes[2].id) - - nodes = created_graph.nodes - links = created_graph.links - assert len(links) == 1 - assert links[0].source_id != links[0].sink_id - assert links[0].source_id in {nodes[0].id, nodes[1].id, nodes[2].id} - assert links[0].sink_id in {nodes[0].id, nodes[1].id, nodes[2].id} - - -@pytest.mark.asyncio(scope="session") -async def test_get_input_schema(server: SpinTestServer): - """ - Test the get_input_schema method of a created graph. - - This test ensures that: - 1. A graph can be created with a single node. - 2. The input schema of the created graph is correctly generated. - 3. The input schema contains the expected input name and node id. - - Args: - server (SpinTestServer): The test server instance. - """ - value_block = StoreValueBlock().id - input_block = AgentInputBlock().id - output_block = AgentOutputBlock().id - - graph = Graph( - name="TestInputSchema", - description="Test input schema", - nodes=[ - Node( - id="node_0_a", - block_id=input_block, - input_default={"name": "in_key_a", "title": "Key A", "value": "A"}, - metadata={"id": "node_0_a"}, - ), - Node( - id="node_0_b", - block_id=input_block, - input_default={"name": "in_key_b", "advanced": True}, - metadata={"id": "node_0_b"}, - ), - Node(id="node_1", block_id=value_block, metadata={"id": "node_1"}), - Node( - id="node_2", - block_id=output_block, - input_default={ - "name": "out_key", - "description": "This is an output key", - }, - metadata={"id": "node_2"}, - ), - ], - links=[ - Link( - source_id="node_0_a", - sink_id="node_1", - source_name="result", - sink_name="input", - ), - Link( - source_id="node_0_b", - sink_id="node_1", - source_name="result", - sink_name="input", - ), - Link( - source_id="node_1", - sink_id="node_2", - source_name="output", - sink_name="value", - ), - ], - ) - - create_graph = CreateGraph(graph=graph) - created_graph = await server.agent_server.test_create_graph( - create_graph, DEFAULT_USER_ID - ) - - class ExpectedInputSchema(BlockSchema): - in_key_a: Any = SchemaField(title="Key A", default="A", advanced=False) - in_key_b: Any = SchemaField(title="in_key_b", advanced=True) - - class ExpectedOutputSchema(BlockSchema): - out_key: Any = SchemaField( - description="This is an output key", - title="out_key", - advanced=False, - ) - - input_schema = created_graph.input_schema - input_schema["title"] = "ExpectedInputSchema" - assert input_schema == ExpectedInputSchema.jsonschema() - - output_schema = created_graph.output_schema - output_schema["title"] = "ExpectedOutputSchema" - assert output_schema == ExpectedOutputSchema.jsonschema() diff --git a/autogpt_platform/backend/test/e2e_test_data.py b/autogpt_platform/backend/test/e2e_test_data.py new file mode 100644 index 000000000000..b183d83d59ec --- /dev/null +++ b/autogpt_platform/backend/test/e2e_test_data.py @@ -0,0 +1,846 @@ +""" +E2E Test Data Creator for AutoGPT Platform + +This script creates test data for E2E tests by using API functions instead of direct Prisma calls. +This approach ensures compatibility with future model changes by using the API layer. + +Image/Video URL Domains Used: +- Images: picsum.photos (for all image URLs - avatars, store listing images, etc.) +- Videos: youtube.com (for store listing video URLs) + +Add these domains to your Next.js config: +```javascript +// next.config.js +images: { + domains: ['picsum.photos'], +} +``` +""" + +import asyncio +import random +from typing import Any, Dict, List + +from faker import Faker + +from backend.data.api_key import generate_api_key +from backend.data.credit import get_user_credit_model +from backend.data.db import prisma +from backend.data.graph import Graph, Link, Node, create_graph + +# Import API functions from the backend +from backend.data.user import get_or_create_user +from backend.server.v2.library.db import create_library_agent, create_preset +from backend.server.v2.library.model import LibraryAgentPresetCreatable +from backend.server.v2.store.db import create_store_submission, review_store_submission +from backend.util.clients import get_supabase + +faker = Faker() + + +# Constants for data generation limits (reduced for E2E tests) +NUM_USERS = 15 +NUM_AGENT_BLOCKS = 30 +MIN_GRAPHS_PER_USER = 15 +MAX_GRAPHS_PER_USER = 15 +MIN_NODES_PER_GRAPH = 3 +MAX_NODES_PER_GRAPH = 6 +MIN_PRESETS_PER_USER = 2 +MAX_PRESETS_PER_USER = 3 +MIN_AGENTS_PER_USER = 15 +MAX_AGENTS_PER_USER = 15 +MIN_EXECUTIONS_PER_GRAPH = 2 +MAX_EXECUTIONS_PER_GRAPH = 8 +MIN_REVIEWS_PER_VERSION = 2 +MAX_REVIEWS_PER_VERSION = 5 + + +def get_image(): + """Generate a consistent image URL using picsum.photos service.""" + width = random.choice([200, 300, 400, 500, 600, 800]) + height = random.choice([200, 300, 400, 500, 600, 800]) + seed = random.randint(1, 1000) + return f"https://picsum.photos/seed/{seed}/{width}/{height}" + + +def get_video_url(): + """Generate a consistent video URL using YouTube.""" + video_ids = [ + "dQw4w9WgXcQ", + "9bZkp7q19f0", + "kJQP7kiw5Fk", + "RgKAFK5djSk", + "L_jWHffIx5E", + ] + video_id = random.choice(video_ids) + return f"https://www.youtube.com/watch?v={video_id}" + + +def get_category(): + """Generate a random category from the predefined list.""" + categories = [ + "productivity", + "writing", + "development", + "data", + "marketing", + "research", + "creative", + "business", + "personal", + "other", + ] + return random.choice(categories) + + +class TestDataCreator: + """Creates test data using API functions for E2E tests.""" + + def __init__(self): + self.users: List[Dict[str, Any]] = [] + self.agent_blocks: List[Dict[str, Any]] = [] + self.agent_graphs: List[Dict[str, Any]] = [] + self.library_agents: List[Dict[str, Any]] = [] + self.store_submissions: List[Dict[str, Any]] = [] + self.api_keys: List[Dict[str, Any]] = [] + self.presets: List[Dict[str, Any]] = [] + self.profiles: List[Dict[str, Any]] = [] + + async def create_test_users(self) -> List[Dict[str, Any]]: + """Create test users using Supabase client.""" + print(f"Creating {NUM_USERS} test users...") + + supabase = get_supabase() + users = [] + + for i in range(NUM_USERS): + try: + # Generate test user data + if i == 0: + # First user should have test123@gmail.com email for testing + email = "test123@gmail.com" + else: + email = faker.unique.email() + password = "testpassword123" # Standard test password + user_id = f"test-user-{i}-{faker.uuid4()}" + + # Create user in Supabase Auth (if needed) + try: + auth_response = supabase.auth.admin.create_user( + {"email": email, "password": password, "email_confirm": True} + ) + if auth_response.user: + user_id = auth_response.user.id + except Exception as supabase_error: + print( + f"Supabase user creation failed for {email}, using fallback: {supabase_error}" + ) + # Fall back to direct database creation + + # Create mock user data similar to what auth middleware would provide + user_data = { + "sub": user_id, + "email": email, + } + + # Use the API function to create user in local database + user = await get_or_create_user(user_data) + users.append(user.model_dump()) + + except Exception as e: + print(f"Error creating user {i}: {e}") + continue + + self.users = users + return users + + async def get_available_blocks(self) -> List[Dict[str, Any]]: + """Get available agent blocks from database.""" + print("Getting available agent blocks...") + + # Get blocks from database instead of the registry + db_blocks = await prisma.agentblock.find_many() + if not db_blocks: + print("No blocks found in database, creating some basic blocks...") + # Create some basic blocks if none exist + from backend.blocks.io import AgentInputBlock, AgentOutputBlock + from backend.blocks.maths import CalculatorBlock + from backend.blocks.time_blocks import GetCurrentTimeBlock + + blocks_to_create = [ + AgentInputBlock(), + AgentOutputBlock(), + CalculatorBlock(), + GetCurrentTimeBlock(), + ] + + for block in blocks_to_create: + try: + await prisma.agentblock.create( + data={ + "id": block.id, + "name": block.name, + "inputSchema": "{}", + "outputSchema": "{}", + } + ) + except Exception as e: + print(f"Error creating block {block.name}: {e}") + + # Get blocks again after creation + db_blocks = await prisma.agentblock.find_many() + + self.agent_blocks = [ + {"id": block.id, "name": block.name} for block in db_blocks + ] + print(f"Found {len(self.agent_blocks)} blocks in database") + return self.agent_blocks + + async def create_test_graphs(self) -> List[Dict[str, Any]]: + """Create test graphs using the API function.""" + print("Creating test graphs...") + + graphs = [] + for user in self.users: + num_graphs = random.randint(MIN_GRAPHS_PER_USER, MAX_GRAPHS_PER_USER) + + for graph_num in range(num_graphs): + # Create a simple graph with nodes and links + graph_id = str(faker.uuid4()) + nodes = [] + links = [] + + # Determine if this should be a DummyInput graph (first 3-4 graphs per user) + is_dummy_input = graph_num < 4 + + # Create nodes based on graph type + if is_dummy_input: + # For dummy input graphs: only GetCurrentTimeBlock + node_id = str(faker.uuid4()) + block = next( + b + for b in self.agent_blocks + if b["name"] == "GetCurrentTimeBlock" + ) + input_default = {"trigger": "start", "format": "%H:%M:%S"} + + node = Node( + id=node_id, + block_id=block["id"], + input_default=input_default, + metadata={"position": {"x": 0, "y": 0}}, + ) + nodes.append(node) + else: + # For regular graphs: Create calculator agent pattern with 4 nodes + # Node 1: AgentInputBlock for 'a' + input_a_id = str(faker.uuid4()) + input_a_block = next( + b for b in self.agent_blocks if b["name"] == "AgentInputBlock" + ) + input_a_node = Node( + id=input_a_id, + block_id=input_a_block["id"], + input_default={ + "name": "a", + "title": None, + "value": "", + "advanced": False, + "description": None, + "placeholder_values": [], + }, + metadata={"position": {"x": -1012, "y": 674}}, + ) + nodes.append(input_a_node) + + # Node 2: AgentInputBlock for 'b' + input_b_id = str(faker.uuid4()) + input_b_block = next( + b for b in self.agent_blocks if b["name"] == "AgentInputBlock" + ) + input_b_node = Node( + id=input_b_id, + block_id=input_b_block["id"], + input_default={ + "name": "b", + "title": None, + "value": "", + "advanced": False, + "description": None, + "placeholder_values": [], + }, + metadata={"position": {"x": -1117, "y": 78}}, + ) + nodes.append(input_b_node) + + # Node 3: CalculatorBlock + calc_id = str(faker.uuid4()) + calc_block = next( + b for b in self.agent_blocks if b["name"] == "CalculatorBlock" + ) + calc_node = Node( + id=calc_id, + block_id=calc_block["id"], + input_default={"operation": "Add", "round_result": False}, + metadata={"position": {"x": -435, "y": 363}}, + ) + nodes.append(calc_node) + + # Node 4: AgentOutputBlock + output_id = str(faker.uuid4()) + output_block = next( + b for b in self.agent_blocks if b["name"] == "AgentOutputBlock" + ) + output_node = Node( + id=output_id, + block_id=output_block["id"], + input_default={ + "name": "result", + "title": None, + "value": "", + "format": "", + "advanced": False, + "description": None, + }, + metadata={"position": {"x": 402, "y": 0}}, + ) + nodes.append(output_node) + + # Create links between nodes (only for non-dummy graphs with multiple nodes) + if len(nodes) >= 4: + # Use the actual node IDs from the created nodes instead of our variables + actual_input_a_id = nodes[0].id # First node (input_a) + actual_input_b_id = nodes[1].id # Second node (input_b) + actual_calc_id = nodes[2].id # Third node (calculator) + actual_output_id = nodes[3].id # Fourth node (output) + + # Link input_a to calculator.a + link1 = Link( + source_id=actual_input_a_id, + sink_id=actual_calc_id, + source_name="result", + sink_name="a", + is_static=True, + ) + links.append(link1) + + # Link input_b to calculator.b + link2 = Link( + source_id=actual_input_b_id, + sink_id=actual_calc_id, + source_name="result", + sink_name="b", + is_static=True, + ) + links.append(link2) + + # Link calculator.result to output.value + link3 = Link( + source_id=actual_calc_id, + sink_id=actual_output_id, + source_name="result", + sink_name="value", + is_static=False, + ) + links.append(link3) + + # Create graph object with DummyInput in name if it's a dummy input graph + graph_name = faker.sentence(nb_words=3) + if is_dummy_input: + graph_name = f"DummyInput {graph_name}" + + graph_name = f"{graph_name} Agents" + + graph = Graph( + id=graph_id, + name=graph_name, + description=faker.text(max_nb_chars=200), + nodes=nodes, + links=links, + is_active=True, + ) + + try: + # Use the API function to create graph + created_graph = await create_graph(graph, user["id"]) + graph_dict = created_graph.model_dump() + # Ensure userId is included for store submissions + graph_dict["userId"] = user["id"] + graphs.append(graph_dict) + print( + f"✅ Created graph for user {user['id']}: {graph_dict['name']}" + ) + except Exception as e: + print(f"Error creating graph: {e}") + continue + + self.agent_graphs = graphs + return graphs + + async def create_test_library_agents(self) -> List[Dict[str, Any]]: + """Create test library agents using the API function.""" + print("Creating test library agents...") + + library_agents = [] + for user in self.users: + num_agents = 10 # Create exactly 10 agents per user + + # Get available graphs for this user + user_graphs = [ + g for g in self.agent_graphs if g.get("userId") == user["id"] + ] + if not user_graphs: + continue + + # Shuffle and take unique graphs to avoid duplicates + random.shuffle(user_graphs) + selected_graphs = user_graphs[: min(num_agents, len(user_graphs))] + + for graph_data in selected_graphs: + try: + # Get the graph model from the database + from backend.data.graph import get_graph + + graph = await get_graph( + graph_data["id"], graph_data.get("version", 1), user["id"] + ) + if graph: + # Use the API function to create library agent + library_agents.extend( + v.model_dump() + for v in await create_library_agent(graph, user["id"]) + ) + except Exception as e: + print(f"Error creating library agent: {e}") + continue + + self.library_agents = library_agents + return library_agents + + async def create_test_presets(self) -> List[Dict[str, Any]]: + """Create test presets using the API function.""" + print("Creating test presets...") + + presets = [] + for user in self.users: + num_presets = random.randint(MIN_PRESETS_PER_USER, MAX_PRESETS_PER_USER) + + # Get available graphs for this user + user_graphs = [ + g for g in self.agent_graphs if g.get("userId") == user["id"] + ] + if not user_graphs: + continue + + for _ in range(min(num_presets, len(user_graphs))): + graph = random.choice(user_graphs) + + preset_data = LibraryAgentPresetCreatable( + name=faker.sentence(nb_words=3), + description=faker.text(max_nb_chars=200), + graph_id=graph["id"], # Fixed field name + graph_version=graph.get("version", 1), # Fixed field name + inputs={}, # Required field - empty inputs for test data + credentials={}, # Required field - empty credentials for test data + is_active=True, + ) + + try: + # Use the API function to create preset + preset = await create_preset(user["id"], preset_data) + presets.append(preset.model_dump()) + except Exception as e: + print(f"Error creating preset: {e}") + continue + + self.presets = presets + return presets + + async def create_test_api_keys(self) -> List[Dict[str, Any]]: + """Create test API keys using the API function.""" + print("Creating test API keys...") + + api_keys = [] + for user in self.users: + from backend.data.api_key import APIKeyPermission + + try: + # Use the API function to create API key + api_key, _ = await generate_api_key( + name=faker.word(), + user_id=user["id"], + permissions=[ + APIKeyPermission.EXECUTE_GRAPH, + APIKeyPermission.READ_GRAPH, + ], + description=faker.text(), + ) + api_keys.append(api_key.model_dump()) + except Exception as e: + print(f"Error creating API key for user {user['id']}: {e}") + continue + + self.api_keys = api_keys + return api_keys + + async def update_test_profiles(self) -> List[Dict[str, Any]]: + """Update existing user profiles to make some into featured creators.""" + print("Updating user profiles to create featured creators...") + + # Get all existing profiles (auto-created when users were created) + existing_profiles = await prisma.profile.find_many( + where={"userId": {"in": [user["id"] for user in self.users]}} + ) + + if not existing_profiles: + print("No existing profiles found. Profiles may not be auto-created.") + return [] + + profiles = [] + # Select about 70% of users to become creators (update their profiles) + num_creators = max(1, int(len(existing_profiles) * 0.7)) + selected_profiles = random.sample( + existing_profiles, min(num_creators, len(existing_profiles)) + ) + + # Mark about 50% of creators as featured (more for testing) + num_featured = max(2, int(num_creators * 0.5)) + num_featured = min( + num_featured, len(selected_profiles) + ) # Don't exceed available profiles + featured_profile_ids = set( + random.sample([p.id for p in selected_profiles], num_featured) + ) + + for profile in selected_profiles: + try: + is_featured = profile.id in featured_profile_ids + + # Update the profile with creator data + updated_profile = await prisma.profile.update( + where={"id": profile.id}, + data={ + "name": faker.name(), + "username": faker.user_name() + + str(random.randint(100, 999)), # Ensure uniqueness + "description": faker.text(max_nb_chars=200), + "links": [faker.url() for _ in range(random.randint(1, 3))], + "avatarUrl": get_image(), + "isFeatured": is_featured, + }, + ) + + if updated_profile: + profiles.append(updated_profile.model_dump()) + + except Exception as e: + print(f"Error updating profile {profile.id}: {e}") + continue + + self.profiles = profiles + return profiles + + async def create_test_store_submissions(self) -> List[Dict[str, Any]]: + """Create test store submissions using the API function.""" + print("Creating test store submissions...") + + submissions = [] + approved_submissions = [] + + # Create a special test submission for test123@gmail.com + test_user = next( + (user for user in self.users if user["email"] == "test123@gmail.com"), None + ) + if test_user: + # Special test data for consistent testing + test_submission_data = { + "user_id": test_user["id"], + "agent_id": self.agent_graphs[0]["id"], # Use first available graph + "agent_version": 1, + "slug": "test-agent-submission", + "name": "Test Agent Submission", + "sub_heading": "A test agent for frontend testing", + "video_url": "https://www.youtube.com/watch?v=test123", + "image_urls": [ + "https://picsum.photos/200/300", + "https://picsum.photos/200/301", + "https://picsum.photos/200/302", + ], + "description": "This is a test agent submission specifically created for frontend testing purposes.", + "categories": ["test", "demo", "frontend"], + "changes_summary": "Initial test submission", + } + + try: + test_submission = await create_store_submission(**test_submission_data) + submissions.append(test_submission.model_dump()) + print("✅ Created special test store submission for test123@gmail.com") + + # Randomly approve, reject, or leave pending the test submission + if test_submission.store_listing_version_id: + random_value = random.random() + if random_value < 0.4: # 40% chance to approve + approved_submission = await review_store_submission( + store_listing_version_id=test_submission.store_listing_version_id, + is_approved=True, + external_comments="Test submission approved", + internal_comments="Auto-approved test submission", + reviewer_id=test_user["id"], + ) + approved_submissions.append(approved_submission.model_dump()) + print("✅ Approved test store submission") + + # Mark approved submission as featured + await prisma.storelistingversion.update( + where={"id": test_submission.store_listing_version_id}, + data={"isFeatured": True}, + ) + print("🌟 Marked test agent as FEATURED") + elif random_value < 0.7: # 30% chance to reject (40% to 70%) + await review_store_submission( + store_listing_version_id=test_submission.store_listing_version_id, + is_approved=False, + external_comments="Test submission rejected - needs improvements", + internal_comments="Auto-rejected test submission for E2E testing", + reviewer_id=test_user["id"], + ) + print("❌ Rejected test store submission") + else: # 30% chance to leave pending (70% to 100%) + print("⏳ Left test submission pending for review") + + except Exception as e: + print(f"Error creating test store submission: {e}") + import traceback + + traceback.print_exc() + + # Create regular submissions for all users + for user in self.users: + # Get available graphs for this specific user + user_graphs = [ + g for g in self.agent_graphs if g.get("userId") == user["id"] + ] + print(f"User {user['id']} has {len(user_graphs)} graphs") + if not user_graphs: + print( + f"No graphs found for user {user['id']}, skipping store submissions" + ) + continue + + # Create exactly 4 store submissions per user + for submission_index in range(4): + graph = random.choice(user_graphs) + + try: + print( + f"Creating store submission for user {user['id']} with graph {graph['id']} (owner: {graph.get('userId')})" + ) + + # Use the API function to create store submission with correct parameters + submission = await create_store_submission( + user_id=user["id"], # Must match graph's userId + agent_id=graph["id"], + agent_version=graph.get("version", 1), + slug=faker.slug(), + name=graph.get("name", faker.sentence(nb_words=3)), + sub_heading=faker.sentence(), + video_url=get_video_url() if random.random() < 0.3 else None, + image_urls=[get_image() for _ in range(3)], + description=faker.text(), + categories=[ + get_category() + ], # Single category from predefined list + changes_summary="Initial E2E test submission", + ) + submissions.append(submission.model_dump()) + print(f"✅ Created store submission: {submission.name}") + + # Randomly approve, reject, or leave pending the submission + if submission.store_listing_version_id: + random_value = random.random() + if random_value < 0.4: # 40% chance to approve + try: + # Pick a random user as the reviewer (admin) + reviewer_id = random.choice(self.users)["id"] + + approved_submission = await review_store_submission( + store_listing_version_id=submission.store_listing_version_id, + is_approved=True, + external_comments="Auto-approved for E2E testing", + internal_comments="Automatically approved by E2E test data script", + reviewer_id=reviewer_id, + ) + approved_submissions.append( + approved_submission.model_dump() + ) + print( + f"✅ Approved store submission: {submission.name}" + ) + + # Mark some agents as featured during creation (30% chance) + # More likely for creators and first submissions + is_creator = user["id"] in [ + p.get("userId") for p in self.profiles + ] + feature_chance = ( + 0.5 if is_creator else 0.2 + ) # 50% for creators, 20% for others + + if random.random() < feature_chance: + try: + await prisma.storelistingversion.update( + where={ + "id": submission.store_listing_version_id + }, + data={"isFeatured": True}, + ) + print( + f"🌟 Marked agent as FEATURED: {submission.name}" + ) + except Exception as e: + print( + f"Warning: Could not mark submission as featured: {e}" + ) + + except Exception as e: + print( + f"Warning: Could not approve submission {submission.name}: {e}" + ) + elif random_value < 0.7: # 30% chance to reject (40% to 70%) + try: + # Pick a random user as the reviewer (admin) + reviewer_id = random.choice(self.users)["id"] + + await review_store_submission( + store_listing_version_id=submission.store_listing_version_id, + is_approved=False, + external_comments="Submission rejected - needs improvements", + internal_comments="Automatically rejected by E2E test data script", + reviewer_id=reviewer_id, + ) + print( + f"❌ Rejected store submission: {submission.name}" + ) + except Exception as e: + print( + f"Warning: Could not reject submission {submission.name}: {e}" + ) + else: # 30% chance to leave pending (70% to 100%) + print( + f"⏳ Left submission pending for review: {submission.name}" + ) + + except Exception as e: + print( + f"Error creating store submission for user {user['id']} graph {graph['id']}: {e}" + ) + import traceback + + traceback.print_exc() + continue + + print( + f"Created {len(submissions)} store submissions, approved {len(approved_submissions)}" + ) + self.store_submissions = submissions + return submissions + + async def add_user_credits(self): + """Add credits to users.""" + print("Adding credits to users...") + + credit_model = get_user_credit_model() + + for user in self.users: + try: + # Skip credits for disabled credit model to avoid errors + if ( + hasattr(credit_model, "__class__") + and "Disabled" in credit_model.__class__.__name__ + ): + print(f"Skipping credits for user {user['id']} - credits disabled") + continue + + # Add random credits to each user + credit_amount = random.randint(100, 1000) + + await credit_model.top_up_credits( + user_id=user["id"], amount=credit_amount + ) + print(f"Added {credit_amount} credits to user {user['id']}") + except Exception: + print( + f"Skipping credits for user {user['id']}: credits may be disabled" + ) + continue + + async def create_all_test_data(self): + """Create all test data.""" + print("Starting E2E test data creation...") + + # Create users first + await self.create_test_users() + + # Get available blocks + await self.get_available_blocks() + + # Create graphs + await self.create_test_graphs() + + # Create library agents + await self.create_test_library_agents() + + # Create presets + await self.create_test_presets() + + # Create API keys + await self.create_test_api_keys() + + # Update user profiles to create featured creators + await self.update_test_profiles() + + # Create store submissions + await self.create_test_store_submissions() + + # Add user credits + await self.add_user_credits() + + # Refresh materialized views + print("Refreshing materialized views...") + try: + await prisma.execute_raw("SELECT refresh_store_materialized_views();") + except Exception as e: + print(f"Error refreshing materialized views: {e}") + + print("E2E test data creation completed successfully!") + + # Print summary + print("\n🎉 E2E Test Data Creation Summary:") + print(f"✅ Users created: {len(self.users)}") + print(f"✅ Agent blocks available: {len(self.agent_blocks)}") + print(f"✅ Agent graphs created: {len(self.agent_graphs)}") + print(f"✅ Library agents created: {len(self.library_agents)}") + print(f"✅ Creator profiles updated: {len(self.profiles)} (some featured)") + print( + f"✅ Store submissions created: {len(self.store_submissions)} (some marked as featured during creation)" + ) + print(f"✅ API keys created: {len(self.api_keys)}") + print(f"✅ Presets created: {len(self.presets)}") + print("\n🚀 Your E2E test database is ready to use!") + + +async def main(): + """Main function to run the test data creation.""" + # Connect to database + await prisma.connect() + + try: + creator = TestDataCreator() + await creator.create_all_test_data() + finally: + # Disconnect from database + await prisma.disconnect() + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/autogpt_platform/backend/test/executor/test_manager.py b/autogpt_platform/backend/test/executor/test_manager.py deleted file mode 100644 index b9dad8895469..000000000000 --- a/autogpt_platform/backend/test/executor/test_manager.py +++ /dev/null @@ -1,289 +0,0 @@ -import logging - -import pytest -from prisma.models import User - -from backend.blocks.basic import FindInDictionaryBlock, StoreValueBlock -from backend.blocks.maths import CalculatorBlock, Operation -from backend.data import execution, graph -from backend.server.model import CreateGraph -from backend.server.rest_api import AgentServer -from backend.usecases.sample import create_test_graph, create_test_user -from backend.util.test import SpinTestServer, wait_execution - -logger = logging.getLogger(__name__) - - -async def create_graph(s: SpinTestServer, g: graph.Graph, u: User) -> graph.Graph: - logger.info(f"Creating graph for user {u.id}") - return await s.agent_server.test_create_graph(CreateGraph(graph=g), u.id) - - -async def execute_graph( - agent_server: AgentServer, - test_graph: graph.Graph, - test_user: User, - input_data: dict, - num_execs: int = 4, -) -> str: - logger.info(f"Executing graph {test_graph.id} for user {test_user.id}") - logger.info(f"Input data: {input_data}") - - # --- Test adding new executions --- # - response = await agent_server.test_execute_graph( - test_graph.id, input_data, test_user.id - ) - graph_exec_id = response["id"] - logger.info(f"Created execution with ID: {graph_exec_id}") - - # Execution queue should be empty - logger.info("Waiting for execution to complete...") - result = await wait_execution(test_user.id, test_graph.id, graph_exec_id) - logger.info(f"Execution completed with {len(result)} results") - assert result and len(result) == num_execs - return graph_exec_id - - -async def assert_sample_graph_executions( - agent_server: AgentServer, - test_graph: graph.Graph, - test_user: User, - graph_exec_id: str, -): - logger.info(f"Checking execution results for graph {test_graph.id}") - executions = await agent_server.test_get_graph_run_node_execution_results( - test_graph.id, - graph_exec_id, - test_user.id, - ) - - output_list = [{"result": ["Hello"]}, {"result": ["World"]}] - input_list = [ - { - "name": "input_1", - "value": "Hello", - }, - { - "name": "input_2", - "value": "World", - }, - ] - - # Executing StoreValueBlock - exec = executions[0] - logger.info(f"Checking first StoreValueBlock execution: {exec}") - assert exec.status == execution.ExecutionStatus.COMPLETED - assert exec.graph_exec_id == graph_exec_id - assert ( - exec.output_data in output_list - ), f"Output data: {exec.output_data} and {output_list}" - assert ( - exec.input_data in input_list - ), f"Input data: {exec.input_data} and {input_list}" - assert exec.node_id in [test_graph.nodes[0].id, test_graph.nodes[1].id] - - # Executing StoreValueBlock - exec = executions[1] - logger.info(f"Checking second StoreValueBlock execution: {exec}") - assert exec.status == execution.ExecutionStatus.COMPLETED - assert exec.graph_exec_id == graph_exec_id - assert ( - exec.output_data in output_list - ), f"Output data: {exec.output_data} and {output_list}" - assert ( - exec.input_data in input_list - ), f"Input data: {exec.input_data} and {input_list}" - assert exec.node_id in [test_graph.nodes[0].id, test_graph.nodes[1].id] - - # Executing FillTextTemplateBlock - exec = executions[2] - logger.info(f"Checking FillTextTemplateBlock execution: {exec}") - assert exec.status == execution.ExecutionStatus.COMPLETED - assert exec.graph_exec_id == graph_exec_id - assert exec.output_data == {"output": ["Hello, World!!!"]} - assert exec.input_data == { - "format": "{a}, {b}{c}", - "values": {"a": "Hello", "b": "World", "c": "!!!"}, - "values_#_a": "Hello", - "values_#_b": "World", - "values_#_c": "!!!", - } - assert exec.node_id == test_graph.nodes[2].id - - # Executing PrintToConsoleBlock - exec = executions[3] - logger.info(f"Checking PrintToConsoleBlock execution: {exec}") - assert exec.status == execution.ExecutionStatus.COMPLETED - assert exec.graph_exec_id == graph_exec_id - assert exec.output_data == {"status": ["printed"]} - assert exec.input_data == {"text": "Hello, World!!!"} - assert exec.node_id == test_graph.nodes[3].id - - -@pytest.mark.asyncio(scope="session") -async def test_agent_execution(server: SpinTestServer): - logger.info("Starting test_agent_execution") - test_user = await create_test_user() - test_graph = await create_graph(server, create_test_graph(), test_user) - data = {"input_1": "Hello", "input_2": "World"} - graph_exec_id = await execute_graph( - server.agent_server, - test_graph, - test_user, - data, - 4, - ) - await assert_sample_graph_executions( - server.agent_server, test_graph, test_user, graph_exec_id - ) - logger.info("Completed test_agent_execution") - - -@pytest.mark.asyncio(scope="session") -async def test_input_pin_always_waited(server: SpinTestServer): - """ - This test is asserting that the input pin should always be waited for the execution, - even when default value on that pin is defined, the value has to be ignored. - - Test scenario: - StoreValueBlock1 - \\ input - >------- FindInDictionaryBlock | input_default: key: "", input: {} - // key - StoreValueBlock2 - """ - logger.info("Starting test_input_pin_always_waited") - nodes = [ - graph.Node( - block_id=StoreValueBlock().id, - input_default={"input": {"key1": "value1", "key2": "value2"}}, - ), - graph.Node( - block_id=StoreValueBlock().id, - input_default={"input": "key2"}, - ), - graph.Node( - block_id=FindInDictionaryBlock().id, - input_default={"key": "", "input": {}}, - ), - ] - links = [ - graph.Link( - source_id=nodes[0].id, - sink_id=nodes[2].id, - source_name="output", - sink_name="input", - ), - graph.Link( - source_id=nodes[1].id, - sink_id=nodes[2].id, - source_name="output", - sink_name="key", - ), - ] - test_graph = graph.Graph( - name="TestGraph", - description="Test graph", - nodes=nodes, - links=links, - ) - test_user = await create_test_user() - test_graph = await create_graph(server, test_graph, test_user) - graph_exec_id = await execute_graph( - server.agent_server, test_graph, test_user, {}, 3 - ) - - logger.info("Checking execution results") - executions = await server.agent_server.test_get_graph_run_node_execution_results( - test_graph.id, graph_exec_id, test_user.id - ) - assert len(executions) == 3 - # FindInDictionaryBlock should wait for the input pin to be provided, - # Hence executing extraction of "key" from {"key1": "value1", "key2": "value2"} - assert executions[2].status == execution.ExecutionStatus.COMPLETED - assert executions[2].output_data == {"output": ["value2"]} - logger.info("Completed test_input_pin_always_waited") - - -@pytest.mark.asyncio(scope="session") -async def test_static_input_link_on_graph(server: SpinTestServer): - """ - This test is asserting the behaviour of static input link, e.g: reusable input link. - - Test scenario: - *StoreValueBlock1*===a=========\\ - *StoreValueBlock2*===a=====\\ || - *StoreValueBlock3*===a===*MathBlock*====b / static====*StoreValueBlock5* - *StoreValueBlock4*=========================================// - - In this test, there will be three input waiting in the MathBlock input pin `a`. - And later, another output is produced on input pin `b`, which is a static link, - this input will complete the input of those three incomplete executions. - """ - logger.info("Starting test_static_input_link_on_graph") - nodes = [ - graph.Node(block_id=StoreValueBlock().id, input_default={"input": 4}), # a - graph.Node(block_id=StoreValueBlock().id, input_default={"input": 4}), # a - graph.Node(block_id=StoreValueBlock().id, input_default={"input": 4}), # a - graph.Node(block_id=StoreValueBlock().id, input_default={"input": 5}), # b - graph.Node(block_id=StoreValueBlock().id), - graph.Node( - block_id=CalculatorBlock().id, - input_default={"operation": Operation.ADD.value}, - ), - ] - links = [ - graph.Link( - source_id=nodes[0].id, - sink_id=nodes[5].id, - source_name="output", - sink_name="a", - ), - graph.Link( - source_id=nodes[1].id, - sink_id=nodes[5].id, - source_name="output", - sink_name="a", - ), - graph.Link( - source_id=nodes[2].id, - sink_id=nodes[5].id, - source_name="output", - sink_name="a", - ), - graph.Link( - source_id=nodes[3].id, - sink_id=nodes[4].id, - source_name="output", - sink_name="input", - ), - graph.Link( - source_id=nodes[4].id, - sink_id=nodes[5].id, - source_name="output", - sink_name="b", - is_static=True, # This is the static link to test. - ), - ] - test_graph = graph.Graph( - name="TestGraph", - description="Test graph", - nodes=nodes, - links=links, - ) - test_user = await create_test_user() - test_graph = await create_graph(server, test_graph, test_user) - graph_exec_id = await execute_graph( - server.agent_server, test_graph, test_user, {}, 8 - ) - logger.info("Checking execution results") - executions = await server.agent_server.test_get_graph_run_node_execution_results( - test_graph.id, graph_exec_id, test_user.id - ) - assert len(executions) == 8 - # The last 3 executions will be a+b=4+5=9 - for i, exec_data in enumerate(executions[-3:]): - logger.info(f"Checking execution {i+1} of last 3: {exec_data}") - assert exec_data.status == execution.ExecutionStatus.COMPLETED - assert exec_data.output_data == {"result": [9]} - logger.info("Completed test_static_input_link_on_graph") diff --git a/autogpt_platform/backend/test/executor/test_scheduler.py b/autogpt_platform/backend/test/executor/test_scheduler.py deleted file mode 100644 index 5e9fbc2bc9a6..000000000000 --- a/autogpt_platform/backend/test/executor/test_scheduler.py +++ /dev/null @@ -1,39 +0,0 @@ -import pytest - -from backend.data import db -from backend.executor import ExecutionScheduler -from backend.server.model import CreateGraph -from backend.usecases.sample import create_test_graph, create_test_user -from backend.util.service import get_service_client -from backend.util.test import SpinTestServer - - -@pytest.mark.asyncio(scope="session") -async def test_agent_schedule(server: SpinTestServer): - await db.connect() - test_user = await create_test_user() - test_graph = await server.agent_server.test_create_graph( - create_graph=CreateGraph(graph=create_test_graph()), - user_id=test_user.id, - ) - - scheduler = get_service_client(ExecutionScheduler) - schedules = scheduler.get_execution_schedules(test_graph.id, test_user.id) - assert len(schedules) == 0 - - schedule = scheduler.add_execution_schedule( - graph_id=test_graph.id, - user_id=test_user.id, - graph_version=1, - cron="0 0 * * *", - input_data={"input": "data"}, - ) - assert schedule - - schedules = scheduler.get_execution_schedules(test_graph.id, test_user.id) - assert len(schedules) == 1 - assert schedules[0].cron == "0 0 * * *" - - scheduler.delete_schedule(schedule.id, user_id=test_user.id) - schedules = scheduler.get_execution_schedules(test_graph.id, user_id=test_user.id) - assert len(schedules) == 0 diff --git a/autogpt_platform/backend/test/sdk/__init__.py b/autogpt_platform/backend/test/sdk/__init__.py new file mode 100644 index 000000000000..9e1b3b181d1e --- /dev/null +++ b/autogpt_platform/backend/test/sdk/__init__.py @@ -0,0 +1 @@ +"""SDK test module.""" diff --git a/autogpt_platform/backend/test/sdk/_config.py b/autogpt_platform/backend/test/sdk/_config.py new file mode 100644 index 000000000000..e64450a6f441 --- /dev/null +++ b/autogpt_platform/backend/test/sdk/_config.py @@ -0,0 +1,20 @@ +""" +Shared configuration for SDK test providers using the SDK pattern. +""" + +from backend.sdk import BlockCostType, ProviderBuilder + +# Configure test providers +test_api = ( + ProviderBuilder("test_api") + .with_api_key("TEST_API_KEY", "Test API Key") + .with_base_cost(5, BlockCostType.RUN) + .build() +) + +test_service = ( + ProviderBuilder("test_service") + .with_api_key("TEST_SERVICE_API_KEY", "Test Service API Key") + .with_base_cost(10, BlockCostType.RUN) + .build() +) diff --git a/autogpt_platform/backend/test/sdk/conftest.py b/autogpt_platform/backend/test/sdk/conftest.py new file mode 100644 index 000000000000..8fa43a5bc08c --- /dev/null +++ b/autogpt_platform/backend/test/sdk/conftest.py @@ -0,0 +1,29 @@ +""" +Configuration for SDK tests. + +This conftest.py file provides basic test setup for SDK unit tests +without requiring the full server infrastructure. +""" + +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture(scope="session") +def server(): + """Mock server fixture for SDK tests.""" + mock_server = MagicMock() + mock_server.agent_server = MagicMock() + mock_server.agent_server.test_create_graph = MagicMock() + return mock_server + + +@pytest.fixture(autouse=True) +def reset_registry(): + """Reset the AutoRegistry before each test.""" + from backend.sdk.registry import AutoRegistry + + AutoRegistry.clear() + yield + AutoRegistry.clear() diff --git a/autogpt_platform/backend/test/sdk/test_sdk_block_creation.py b/autogpt_platform/backend/test/sdk/test_sdk_block_creation.py new file mode 100644 index 000000000000..bb26913e566b --- /dev/null +++ b/autogpt_platform/backend/test/sdk/test_sdk_block_creation.py @@ -0,0 +1,914 @@ +""" +Tests for creating blocks using the SDK. + +This test suite verifies that blocks can be created using only SDK imports +and that they work correctly without decorators. +""" + +from typing import Any, Optional, Union + +import pytest + +from backend.sdk import ( + APIKeyCredentials, + Block, + BlockCategory, + BlockCostType, + BlockOutput, + BlockSchema, + CredentialsMetaInput, + OAuth2Credentials, + ProviderBuilder, + SchemaField, + SecretStr, +) + +from ._config import test_api, test_service + + +class TestBasicBlockCreation: + """Test creating basic blocks using the SDK.""" + + @pytest.mark.asyncio + async def test_simple_block(self): + """Test creating a simple block without any decorators.""" + + class SimpleBlock(Block): + """A simple test block.""" + + class Input(BlockSchema): + text: str = SchemaField(description="Input text") + count: int = SchemaField(description="Repeat count", default=1) + + class Output(BlockSchema): + result: str = SchemaField(description="Output result") + + def __init__(self): + super().__init__( + id="simple-test-block", + description="A simple test block", + categories={BlockCategory.TEXT}, + input_schema=SimpleBlock.Input, + output_schema=SimpleBlock.Output, + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + result = input_data.text * input_data.count + yield "result", result + + # Create and test the block + block = SimpleBlock() + assert block.id == "simple-test-block" + assert BlockCategory.TEXT in block.categories + + # Test execution + outputs = [] + async for name, value in block.run( + SimpleBlock.Input(text="Hello ", count=3), + ): + outputs.append((name, value)) + assert len(outputs) == 1 + assert outputs[0] == ("result", "Hello Hello Hello ") + + @pytest.mark.asyncio + async def test_block_with_credentials(self): + """Test creating a block that requires credentials.""" + + class APIBlock(Block): + """A block that requires API credentials.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = test_api.credentials_field( + description="API credentials for test service", + ) + query: str = SchemaField(description="API query") + + class Output(BlockSchema): + response: str = SchemaField(description="API response") + authenticated: bool = SchemaField(description="Was authenticated") + + def __init__(self): + super().__init__( + id="api-test-block", + description="Test block with API credentials", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=APIBlock.Input, + output_schema=APIBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + # Simulate API call + api_key = credentials.api_key.get_secret_value() + authenticated = bool(api_key) + + yield "response", f"API response for: {input_data.query}" + yield "authenticated", authenticated + + # Create test credentials + test_creds = APIKeyCredentials( + id="test-creds", + provider="test_api", + api_key=SecretStr("test-api-key"), + title="Test API Key", + ) + + # Create and test the block + block = APIBlock() + outputs = [] + async for name, value in block.run( + APIBlock.Input( + credentials={ # type: ignore + "provider": "test_api", + "id": "test-creds", + "type": "api_key", + }, + query="test query", + ), + credentials=test_creds, + ): + outputs.append((name, value)) + + assert len(outputs) == 2 + assert outputs[0] == ("response", "API response for: test query") + assert outputs[1] == ("authenticated", True) + + @pytest.mark.asyncio + async def test_block_with_multiple_outputs(self): + """Test block that yields multiple outputs.""" + + class MultiOutputBlock(Block): + """Block with multiple outputs.""" + + class Input(BlockSchema): + text: str = SchemaField(description="Input text") + + class Output(BlockSchema): + uppercase: str = SchemaField(description="Uppercase version") + lowercase: str = SchemaField(description="Lowercase version") + length: int = SchemaField(description="Text length") + is_empty: bool = SchemaField(description="Is text empty") + + def __init__(self): + super().__init__( + id="multi-output-block", + description="Block with multiple outputs", + categories={BlockCategory.TEXT}, + input_schema=MultiOutputBlock.Input, + output_schema=MultiOutputBlock.Output, + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + text = input_data.text + yield "uppercase", text.upper() + yield "lowercase", text.lower() + yield "length", len(text) + yield "is_empty", len(text) == 0 + + # Test the block + block = MultiOutputBlock() + outputs = [] + async for name, value in block.run(MultiOutputBlock.Input(text="Hello World")): + outputs.append((name, value)) + + assert len(outputs) == 4 + assert ("uppercase", "HELLO WORLD") in outputs + assert ("lowercase", "hello world") in outputs + assert ("length", 11) in outputs + assert ("is_empty", False) in outputs + + +class TestBlockWithProvider: + """Test creating blocks associated with providers.""" + + @pytest.mark.asyncio + async def test_block_using_provider(self): + """Test block that uses a registered provider.""" + + class TestServiceBlock(Block): + """Block for test service.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = test_service.credentials_field( + description="Test service credentials", + ) + action: str = SchemaField(description="Action to perform") + + class Output(BlockSchema): + result: str = SchemaField(description="Action result") + provider_name: str = SchemaField(description="Provider used") + + def __init__(self): + super().__init__( + id="test-service-block", + description="Block using test service provider", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=TestServiceBlock.Input, + output_schema=TestServiceBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + # The provider name should match + yield "result", f"Performed: {input_data.action}" + yield "provider_name", credentials.provider + + # Create credentials for our provider + creds = APIKeyCredentials( + id="test-service-creds", + provider="test_service", + api_key=SecretStr("test-key"), + title="Test Service Key", + ) + + # Test the block + block = TestServiceBlock() + outputs = {} + async for name, value in block.run( + TestServiceBlock.Input( + credentials={ # type: ignore + "provider": "test_service", + "id": "test-service-creds", + "type": "api_key", + }, + action="test action", + ), + credentials=creds, + ): + outputs[name] = value + + assert outputs["result"] == "Performed: test action" + assert outputs["provider_name"] == "test_service" + + +class TestComplexBlockScenarios: + """Test more complex block scenarios.""" + + @pytest.mark.asyncio + async def test_block_with_optional_fields(self): + """Test block with optional input fields.""" + # Optional is already imported at the module level + + class OptionalFieldBlock(Block): + """Block with optional fields.""" + + class Input(BlockSchema): + required_field: str = SchemaField(description="Required field") + optional_field: Optional[str] = SchemaField( + description="Optional field", + default=None, + ) + optional_with_default: str = SchemaField( + description="Optional with default", + default="default value", + ) + + class Output(BlockSchema): + has_optional: bool = SchemaField(description="Has optional value") + optional_value: Optional[str] = SchemaField( + description="Optional value" + ) + default_value: str = SchemaField(description="Default value") + + def __init__(self): + super().__init__( + id="optional-field-block", + description="Block with optional fields", + categories={BlockCategory.TEXT}, + input_schema=OptionalFieldBlock.Input, + output_schema=OptionalFieldBlock.Output, + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + yield "has_optional", input_data.optional_field is not None + yield "optional_value", input_data.optional_field + yield "default_value", input_data.optional_with_default + + # Test with optional field provided + block = OptionalFieldBlock() + outputs = {} + async for name, value in block.run( + OptionalFieldBlock.Input( + required_field="test", + optional_field="provided", + ) + ): + outputs[name] = value + + assert outputs["has_optional"] is True + assert outputs["optional_value"] == "provided" + assert outputs["default_value"] == "default value" + + # Test without optional field + outputs = {} + async for name, value in block.run( + OptionalFieldBlock.Input( + required_field="test", + ) + ): + outputs[name] = value + + assert outputs["has_optional"] is False + assert outputs["optional_value"] is None + assert outputs["default_value"] == "default value" + + @pytest.mark.asyncio + async def test_block_with_complex_types(self): + """Test block with complex input/output types.""" + + class ComplexBlock(Block): + """Block with complex types.""" + + class Input(BlockSchema): + items: list[str] = SchemaField(description="List of items") + mapping: dict[str, int] = SchemaField( + description="String to int mapping" + ) + + class Output(BlockSchema): + item_count: int = SchemaField(description="Number of items") + total_value: int = SchemaField(description="Sum of mapping values") + combined: list[str] = SchemaField(description="Combined results") + + def __init__(self): + super().__init__( + id="complex-types-block", + description="Block with complex types", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=ComplexBlock.Input, + output_schema=ComplexBlock.Output, + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + yield "item_count", len(input_data.items) + yield "total_value", sum(input_data.mapping.values()) + + # Combine items with their mapping values + combined = [] + for item in input_data.items: + value = input_data.mapping.get(item, 0) + combined.append(f"{item}: {value}") + + yield "combined", combined + + # Test the block + block = ComplexBlock() + outputs = {} + async for name, value in block.run( + ComplexBlock.Input( + items=["apple", "banana", "orange"], + mapping={"apple": 5, "banana": 3, "orange": 4}, + ) + ): + outputs[name] = value + + assert outputs["item_count"] == 3 + assert outputs["total_value"] == 12 + assert outputs["combined"] == ["apple: 5", "banana: 3", "orange: 4"] + + @pytest.mark.asyncio + async def test_block_error_handling(self): + """Test block error handling.""" + + class ErrorHandlingBlock(Block): + """Block that demonstrates error handling.""" + + class Input(BlockSchema): + value: int = SchemaField(description="Input value") + should_error: bool = SchemaField( + description="Whether to trigger an error", + default=False, + ) + + class Output(BlockSchema): + result: int = SchemaField(description="Result") + error_message: Optional[str] = SchemaField( + description="Error if any", default=None + ) + + def __init__(self): + super().__init__( + id="error-handling-block", + description="Block with error handling", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=ErrorHandlingBlock.Input, + output_schema=ErrorHandlingBlock.Output, + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + if input_data.should_error: + raise ValueError("Intentional error triggered") + + if input_data.value < 0: + yield "error_message", "Value must be non-negative" + yield "result", 0 + else: + yield "result", input_data.value * 2 + yield "error_message", None + + # Test normal operation + block = ErrorHandlingBlock() + outputs = {} + async for name, value in block.run( + ErrorHandlingBlock.Input(value=5, should_error=False) + ): + outputs[name] = value + + assert outputs["result"] == 10 + assert outputs["error_message"] is None + + # Test with negative value + outputs = {} + async for name, value in block.run( + ErrorHandlingBlock.Input(value=-5, should_error=False) + ): + outputs[name] = value + + assert outputs["result"] == 0 + assert outputs["error_message"] == "Value must be non-negative" + + # Test with error + with pytest.raises(ValueError, match="Intentional error triggered"): + async for _ in block.run( + ErrorHandlingBlock.Input(value=5, should_error=True) + ): + pass + + +class TestAuthenticationVariants: + """Test complex authentication scenarios including OAuth, API keys, and scopes.""" + + @pytest.mark.asyncio + async def test_oauth_block_with_scopes(self): + """Test creating a block that uses OAuth2 with scopes.""" + from backend.sdk import OAuth2Credentials, ProviderBuilder + + # Create a test OAuth provider with scopes + # For testing, we don't need an actual OAuth handler + # In real usage, you would provide a proper OAuth handler class + oauth_provider = ( + ProviderBuilder("test_oauth_provider") + .with_api_key("TEST_OAUTH_API", "Test OAuth API") + .with_base_cost(5, BlockCostType.RUN) + .build() + ) + + class OAuthScopedBlock(Block): + """Block requiring OAuth2 with specific scopes.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = oauth_provider.credentials_field( + description="OAuth2 credentials with scopes", + scopes=["read:user", "write:data"], + ) + resource: str = SchemaField(description="Resource to access") + + class Output(BlockSchema): + data: str = SchemaField(description="Retrieved data") + scopes_used: list[str] = SchemaField( + description="Scopes that were used" + ) + token_info: dict[str, Any] = SchemaField( + description="Token information" + ) + + def __init__(self): + super().__init__( + id="oauth-scoped-block", + description="Test OAuth2 with scopes", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=OAuthScopedBlock.Input, + output_schema=OAuthScopedBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: OAuth2Credentials, **kwargs + ) -> BlockOutput: + # Simulate OAuth API call with scopes + token = credentials.access_token.get_secret_value() + + yield "data", f"OAuth data for {input_data.resource}" + yield "scopes_used", credentials.scopes or [] + yield "token_info", { + "has_token": bool(token), + "has_refresh": credentials.refresh_token is not None, + "provider": credentials.provider, + "expires_at": credentials.access_token_expires_at, + } + + # Create test OAuth credentials + test_oauth_creds = OAuth2Credentials( + id="test-oauth-creds", + provider="test_oauth_provider", + access_token=SecretStr("test-access-token"), + refresh_token=SecretStr("test-refresh-token"), + scopes=["read:user", "write:data"], + title="Test OAuth Credentials", + ) + + # Test the block + block = OAuthScopedBlock() + outputs = {} + async for name, value in block.run( + OAuthScopedBlock.Input( + credentials={ # type: ignore + "provider": "test_oauth_provider", + "id": "test-oauth-creds", + "type": "oauth2", + }, + resource="user/profile", + ), + credentials=test_oauth_creds, + ): + outputs[name] = value + + assert outputs["data"] == "OAuth data for user/profile" + assert set(outputs["scopes_used"]) == {"read:user", "write:data"} + assert outputs["token_info"]["has_token"] is True + assert outputs["token_info"]["expires_at"] is None + assert outputs["token_info"]["has_refresh"] is True + + @pytest.mark.asyncio + async def test_mixed_auth_block(self): + """Test block that supports both OAuth2 and API key authentication.""" + # No need to import these again, already imported at top + + # Create provider supporting both auth types + # Create provider supporting API key auth + # In real usage, you would add OAuth support with .with_oauth() + mixed_provider = ( + ProviderBuilder("mixed_auth_provider") + .with_api_key("MIXED_API_KEY", "Mixed Provider API Key") + .with_base_cost(8, BlockCostType.RUN) + .build() + ) + + class MixedAuthBlock(Block): + """Block supporting multiple authentication methods.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = mixed_provider.credentials_field( + description="API key or OAuth2 credentials", + supported_credential_types=["api_key", "oauth2"], + ) + operation: str = SchemaField(description="Operation to perform") + + class Output(BlockSchema): + result: str = SchemaField(description="Operation result") + auth_type: str = SchemaField(description="Authentication type used") + auth_details: dict[str, Any] = SchemaField(description="Auth details") + + def __init__(self): + super().__init__( + id="mixed-auth-block", + description="Block supporting OAuth2 and API key", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=MixedAuthBlock.Input, + output_schema=MixedAuthBlock.Output, + ) + + async def run( + self, + input_data: Input, + *, + credentials: Union[APIKeyCredentials, OAuth2Credentials], + **kwargs, + ) -> BlockOutput: + # Handle different credential types + if isinstance(credentials, APIKeyCredentials): + auth_type = "api_key" + auth_details = { + "has_key": bool(credentials.api_key.get_secret_value()), + "key_prefix": credentials.api_key.get_secret_value()[:5] + + "...", + } + elif isinstance(credentials, OAuth2Credentials): + auth_type = "oauth2" + auth_details = { + "has_token": bool(credentials.access_token.get_secret_value()), + "scopes": credentials.scopes or [], + } + else: + auth_type = "unknown" + auth_details = {} + + yield "result", f"Performed {input_data.operation} with {auth_type}" + yield "auth_type", auth_type + yield "auth_details", auth_details + + # Test with API key + api_creds = APIKeyCredentials( + id="mixed-api-creds", + provider="mixed_auth_provider", + api_key=SecretStr("sk-1234567890"), + title="Mixed API Key", + ) + + block = MixedAuthBlock() + outputs = {} + async for name, value in block.run( + MixedAuthBlock.Input( + credentials={ # type: ignore + "provider": "mixed_auth_provider", + "id": "mixed-api-creds", + "type": "api_key", + }, + operation="fetch_data", + ), + credentials=api_creds, + ): + outputs[name] = value + + assert outputs["auth_type"] == "api_key" + assert outputs["result"] == "Performed fetch_data with api_key" + assert outputs["auth_details"]["key_prefix"] == "sk-12..." + + # Test with OAuth2 + oauth_creds = OAuth2Credentials( + id="mixed-oauth-creds", + provider="mixed_auth_provider", + access_token=SecretStr("oauth-token-123"), + scopes=["full_access"], + title="Mixed OAuth", + ) + + outputs = {} + async for name, value in block.run( + MixedAuthBlock.Input( + credentials={ # type: ignore + "provider": "mixed_auth_provider", + "id": "mixed-oauth-creds", + "type": "oauth2", + }, + operation="update_data", + ), + credentials=oauth_creds, + ): + outputs[name] = value + + assert outputs["auth_type"] == "oauth2" + assert outputs["result"] == "Performed update_data with oauth2" + assert outputs["auth_details"]["scopes"] == ["full_access"] + + @pytest.mark.asyncio + async def test_multiple_credentials_block(self): + """Test block requiring multiple different credentials.""" + from backend.sdk import ProviderBuilder + + # Create multiple providers + primary_provider = ( + ProviderBuilder("primary_service") + .with_api_key("PRIMARY_API_KEY", "Primary Service Key") + .build() + ) + + # For testing purposes, using API key instead of OAuth handler + secondary_provider = ( + ProviderBuilder("secondary_service") + .with_api_key("SECONDARY_API_KEY", "Secondary Service Key") + .build() + ) + + class MultiCredentialBlock(Block): + """Block requiring credentials from multiple services.""" + + class Input(BlockSchema): + primary_credentials: CredentialsMetaInput = ( + primary_provider.credentials_field( + description="Primary service API key" + ) + ) + secondary_credentials: CredentialsMetaInput = ( + secondary_provider.credentials_field( + description="Secondary service OAuth" + ) + ) + merge_data: bool = SchemaField( + description="Whether to merge data from both services", + default=True, + ) + + class Output(BlockSchema): + primary_data: str = SchemaField(description="Data from primary service") + secondary_data: str = SchemaField( + description="Data from secondary service" + ) + merged_result: Optional[str] = SchemaField( + description="Merged data if requested" + ) + + def __init__(self): + super().__init__( + id="multi-credential-block", + description="Block using multiple credentials", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=MultiCredentialBlock.Input, + output_schema=MultiCredentialBlock.Output, + ) + + async def run( + self, + input_data: Input, + *, + primary_credentials: APIKeyCredentials, + secondary_credentials: OAuth2Credentials, + **kwargs, + ) -> BlockOutput: + # Simulate fetching data with primary API key + primary_data = f"Primary data using {primary_credentials.provider}" + yield "primary_data", primary_data + + # Simulate fetching data with secondary OAuth + secondary_data = f"Secondary data with {len(secondary_credentials.scopes or [])} scopes" + yield "secondary_data", secondary_data + + # Merge if requested + if input_data.merge_data: + merged = f"{primary_data} + {secondary_data}" + yield "merged_result", merged + else: + yield "merged_result", None + + # Create test credentials + primary_creds = APIKeyCredentials( + id="primary-creds", + provider="primary_service", + api_key=SecretStr("primary-key-123"), + title="Primary Key", + ) + + secondary_creds = OAuth2Credentials( + id="secondary-creds", + provider="secondary_service", + access_token=SecretStr("secondary-token"), + scopes=["read", "write"], + title="Secondary OAuth", + ) + + # Test the block + block = MultiCredentialBlock() + outputs = {} + + # Note: In real usage, the framework would inject the correct credentials + # based on the field names. Here we simulate that behavior. + async for name, value in block.run( + MultiCredentialBlock.Input( + primary_credentials={ # type: ignore + "provider": "primary_service", + "id": "primary-creds", + "type": "api_key", + }, + secondary_credentials={ # type: ignore + "provider": "secondary_service", + "id": "secondary-creds", + "type": "oauth2", + }, + merge_data=True, + ), + primary_credentials=primary_creds, + secondary_credentials=secondary_creds, + ): + outputs[name] = value + + assert outputs["primary_data"] == "Primary data using primary_service" + assert outputs["secondary_data"] == "Secondary data with 2 scopes" + assert "Primary data" in outputs["merged_result"] + assert "Secondary data" in outputs["merged_result"] + + @pytest.mark.asyncio + async def test_oauth_scope_validation(self): + """Test OAuth scope validation and handling.""" + from backend.sdk import OAuth2Credentials, ProviderBuilder + + # Provider with specific required scopes + # For testing OAuth scope validation + scoped_provider = ( + ProviderBuilder("scoped_oauth_service") + .with_api_key("SCOPED_OAUTH_KEY", "Scoped OAuth Service") + .build() + ) + + class ScopeValidationBlock(Block): + """Block that validates OAuth scopes.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = scoped_provider.credentials_field( + description="OAuth credentials with specific scopes", + scopes=["user:read", "user:write"], # Required scopes + ) + require_admin: bool = SchemaField( + description="Whether admin scopes are required", + default=False, + ) + + class Output(BlockSchema): + allowed_operations: list[str] = SchemaField( + description="Operations allowed with current scopes" + ) + missing_scopes: list[str] = SchemaField( + description="Scopes that are missing for full access" + ) + has_required_scopes: bool = SchemaField( + description="Whether all required scopes are present" + ) + + def __init__(self): + super().__init__( + id="scope-validation-block", + description="Block that validates OAuth scopes", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=ScopeValidationBlock.Input, + output_schema=ScopeValidationBlock.Output, + ) + + async def run( + self, input_data: Input, *, credentials: OAuth2Credentials, **kwargs + ) -> BlockOutput: + current_scopes = set(credentials.scopes or []) + required_scopes = {"user:read", "user:write"} + + if input_data.require_admin: + required_scopes.update({"admin:read", "admin:write"}) + + # Determine allowed operations based on scopes + allowed_ops = [] + if "user:read" in current_scopes: + allowed_ops.append("read_user_data") + if "user:write" in current_scopes: + allowed_ops.append("update_user_data") + if "admin:read" in current_scopes: + allowed_ops.append("read_admin_data") + if "admin:write" in current_scopes: + allowed_ops.append("update_admin_data") + + missing = list(required_scopes - current_scopes) + has_required = len(missing) == 0 + + yield "allowed_operations", allowed_ops + yield "missing_scopes", missing + yield "has_required_scopes", has_required + + # Test with partial scopes + partial_creds = OAuth2Credentials( + id="partial-oauth", + provider="scoped_oauth_service", + access_token=SecretStr("partial-token"), + scopes=["user:read"], # Only one of the required scopes + title="Partial OAuth", + ) + + block = ScopeValidationBlock() + outputs = {} + async for name, value in block.run( + ScopeValidationBlock.Input( + credentials={ # type: ignore + "provider": "scoped_oauth_service", + "id": "partial-oauth", + "type": "oauth2", + }, + require_admin=False, + ), + credentials=partial_creds, + ): + outputs[name] = value + + assert outputs["allowed_operations"] == ["read_user_data"] + assert "user:write" in outputs["missing_scopes"] + assert outputs["has_required_scopes"] is False + + # Test with all required scopes + full_creds = OAuth2Credentials( + id="full-oauth", + provider="scoped_oauth_service", + access_token=SecretStr("full-token"), + scopes=["user:read", "user:write", "admin:read"], + title="Full OAuth", + ) + + outputs = {} + async for name, value in block.run( + ScopeValidationBlock.Input( + credentials={ # type: ignore + "provider": "scoped_oauth_service", + "id": "full-oauth", + "type": "oauth2", + }, + require_admin=False, + ), + credentials=full_creds, + ): + outputs[name] = value + + assert set(outputs["allowed_operations"]) == { + "read_user_data", + "update_user_data", + "read_admin_data", + } + assert outputs["missing_scopes"] == [] + assert outputs["has_required_scopes"] is True + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/autogpt_platform/backend/test/sdk/test_sdk_patching.py b/autogpt_platform/backend/test/sdk/test_sdk_patching.py new file mode 100644 index 000000000000..42ea47bb434a --- /dev/null +++ b/autogpt_platform/backend/test/sdk/test_sdk_patching.py @@ -0,0 +1,151 @@ +""" +Tests for the SDK's integration patching mechanism. + +This test suite verifies that the AutoRegistry correctly patches +existing integration points to include SDK-registered components. +""" + +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from backend.integrations.providers import ProviderName +from backend.sdk import ( + AutoRegistry, + BaseOAuthHandler, + BaseWebhooksManager, + Credentials, + ProviderBuilder, +) + + +class MockOAuthHandler(BaseOAuthHandler): + """Mock OAuth handler for testing.""" + + PROVIDER_NAME = ProviderName.GITHUB + + @classmethod + async def authorize(cls, *args, **kwargs): + return "mock_auth" + + +class MockWebhookManager(BaseWebhooksManager): + """Mock webhook manager for testing.""" + + PROVIDER_NAME = ProviderName.GITHUB + + @classmethod + async def validate_payload(cls, webhook, request, credentials: Credentials | None): + return {}, "test_event" + + async def _register_webhook(self, *args, **kwargs): + return "mock_webhook_id", {} + + async def _deregister_webhook(self, *args, **kwargs): + pass + + +class TestWebhookPatching: + """Test webhook manager patching functionality.""" + + def setup_method(self): + """Clear registry.""" + AutoRegistry.clear() + + def test_webhook_manager_patching(self): + """Test that webhook managers are correctly patched.""" + + # Mock the original load_webhook_managers function + def mock_load_webhook_managers(): + return { + "existing_webhook": Mock(spec=BaseWebhooksManager), + } + + # Register a provider with webhooks + ( + ProviderBuilder("webhook_provider") + .with_webhook_manager(MockWebhookManager) + .build() + ) + + # Mock the webhooks module + mock_webhooks_module = MagicMock() + mock_webhooks_module.load_webhook_managers = mock_load_webhook_managers + + with patch.dict( + "sys.modules", {"backend.integrations.webhooks": mock_webhooks_module} + ): + AutoRegistry.patch_integrations() + + # Call the patched function + result = mock_webhooks_module.load_webhook_managers() + + # Original webhook should still exist + assert "existing_webhook" in result + + # New webhook should be added + assert "webhook_provider" in result + assert result["webhook_provider"] == MockWebhookManager + + def test_webhook_patching_no_original_function(self): + """Test webhook patching when load_webhook_managers doesn't exist.""" + # Mock webhooks module without load_webhook_managers + mock_webhooks_module = MagicMock(spec=[]) + + # Register a provider + ( + ProviderBuilder("test_provider") + .with_webhook_manager(MockWebhookManager) + .build() + ) + + with patch.dict( + "sys.modules", {"backend.integrations.webhooks": mock_webhooks_module} + ): + # Should not raise an error + AutoRegistry.patch_integrations() + + # Function should not be added if it didn't exist + assert not hasattr(mock_webhooks_module, "load_webhook_managers") + + +class TestPatchingIntegration: + """Test the complete patching integration flow.""" + + def setup_method(self): + """Clear registry.""" + AutoRegistry.clear() + + def test_complete_provider_registration_and_patching(self): + """Test the complete flow from provider registration to patching.""" + # Mock webhooks module + mock_webhooks = MagicMock() + mock_webhooks.load_webhook_managers = lambda: {"original": Mock()} + + # Create a fully featured provider + ( + ProviderBuilder("complete_provider") + .with_api_key("COMPLETE_KEY", "Complete API Key") + .with_oauth(MockOAuthHandler, scopes=["read", "write"]) + .with_webhook_manager(MockWebhookManager) + .build() + ) + + # Apply patches + with patch.dict( + "sys.modules", + { + "backend.integrations.webhooks": mock_webhooks, + }, + ): + AutoRegistry.patch_integrations() + + # Verify webhook patching + webhook_result = mock_webhooks.load_webhook_managers() + assert "complete_provider" in webhook_result + assert webhook_result["complete_provider"] == MockWebhookManager + assert "original" in webhook_result # Original preserved + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/autogpt_platform/backend/test/sdk/test_sdk_registry.py b/autogpt_platform/backend/test/sdk/test_sdk_registry.py new file mode 100644 index 000000000000..4970ba5933eb --- /dev/null +++ b/autogpt_platform/backend/test/sdk/test_sdk_registry.py @@ -0,0 +1,521 @@ +""" +Tests for the SDK auto-registration system via AutoRegistry. + +This test suite verifies: +1. Provider registration and retrieval +2. OAuth handler registration via patches +3. Webhook manager registration via patches +4. Credential registration and management +5. Block configuration association +""" + +import os +from unittest.mock import MagicMock, Mock, patch + +import pytest + +from backend.integrations.providers import ProviderName +from backend.sdk import ( + APIKeyCredentials, + AutoRegistry, + BaseOAuthHandler, + BaseWebhooksManager, + Block, + BlockConfiguration, + Provider, + ProviderBuilder, +) + + +class TestAutoRegistry: + """Test the AutoRegistry functionality.""" + + def setup_method(self): + """Clear registry before each test.""" + AutoRegistry.clear() + + def test_provider_registration(self): + """Test that providers can be registered and retrieved.""" + # Create a test provider + provider = Provider( + name="test_provider", + oauth_handler=None, + webhook_manager=None, + default_credentials=[], + base_costs=[], + supported_auth_types={"api_key"}, + ) + + # Register it + AutoRegistry.register_provider(provider) + + # Verify it's registered + assert "test_provider" in AutoRegistry._providers + assert AutoRegistry.get_provider("test_provider") == provider + + def test_provider_with_oauth(self): + """Test provider registration with OAuth handler.""" + + # Create a mock OAuth handler + class TestOAuthHandler(BaseOAuthHandler): + PROVIDER_NAME = ProviderName.GITHUB + + from backend.sdk.provider import OAuthConfig + + # Set environment variables so OAuth handler gets registered + with patch.dict( + os.environ, + {"TEST_CLIENT_ID": "test_id", "TEST_CLIENT_SECRET": "test_secret"}, + ): + provider = Provider( + name="oauth_provider", + oauth_config=OAuthConfig( + oauth_handler=TestOAuthHandler, + client_id_env_var="TEST_CLIENT_ID", + client_secret_env_var="TEST_CLIENT_SECRET", + ), + webhook_manager=None, + default_credentials=[], + base_costs=[], + supported_auth_types={"oauth2"}, + ) + + AutoRegistry.register_provider(provider) + + # Verify OAuth handler is registered + assert "oauth_provider" in AutoRegistry._oauth_handlers + assert AutoRegistry._oauth_handlers["oauth_provider"] == TestOAuthHandler + + def test_provider_with_webhook_manager(self): + """Test provider registration with webhook manager.""" + + # Create a mock webhook manager + class TestWebhookManager(BaseWebhooksManager): + PROVIDER_NAME = ProviderName.GITHUB + + provider = Provider( + name="webhook_provider", + oauth_handler=None, + webhook_manager=TestWebhookManager, + default_credentials=[], + base_costs=[], + supported_auth_types={"api_key"}, + ) + + AutoRegistry.register_provider(provider) + + # Verify webhook manager is registered + assert "webhook_provider" in AutoRegistry._webhook_managers + assert AutoRegistry._webhook_managers["webhook_provider"] == TestWebhookManager + + def test_default_credentials_registration(self): + """Test that default credentials are registered.""" + # Create test credentials + from backend.sdk import SecretStr + + cred1 = APIKeyCredentials( + id="test-cred-1", + provider="test_provider", + api_key=SecretStr("test-key-1"), + title="Test Credential 1", + ) + cred2 = APIKeyCredentials( + id="test-cred-2", + provider="test_provider", + api_key=SecretStr("test-key-2"), + title="Test Credential 2", + ) + + provider = Provider( + name="test_provider", + oauth_handler=None, + webhook_manager=None, + default_credentials=[cred1, cred2], + base_costs=[], + supported_auth_types={"api_key"}, + ) + + AutoRegistry.register_provider(provider) + + # Verify credentials are registered + all_creds = AutoRegistry.get_all_credentials() + assert cred1 in all_creds + assert cred2 in all_creds + + def test_api_key_registration(self): + """Test API key environment variable registration.""" + import os + + # Set up a test environment variable + os.environ["TEST_API_KEY"] = "test-api-key-value" + + try: + AutoRegistry.register_api_key("test_provider", "TEST_API_KEY") + + # Verify the mapping is stored + assert AutoRegistry._api_key_mappings["test_provider"] == "TEST_API_KEY" + + # Verify a credential was created + all_creds = AutoRegistry.get_all_credentials() + test_cred = next( + (c for c in all_creds if c.id == "test_provider-default"), None + ) + assert test_cred is not None + assert test_cred.provider == "test_provider" + assert test_cred.api_key.get_secret_value() == "test-api-key-value" # type: ignore + + finally: + # Clean up + del os.environ["TEST_API_KEY"] + + def test_get_oauth_handlers(self): + """Test retrieving all OAuth handlers.""" + + # Register multiple providers with OAuth + class TestOAuth1(BaseOAuthHandler): + PROVIDER_NAME = ProviderName.GITHUB + + class TestOAuth2(BaseOAuthHandler): + PROVIDER_NAME = ProviderName.GOOGLE + + from backend.sdk.provider import OAuthConfig + + # Set environment variables so OAuth handlers get registered + with patch.dict( + os.environ, + {"TEST_CLIENT_ID": "test_id", "TEST_CLIENT_SECRET": "test_secret"}, + ): + provider1 = Provider( + name="provider1", + oauth_config=OAuthConfig( + oauth_handler=TestOAuth1, + client_id_env_var="TEST_CLIENT_ID", + client_secret_env_var="TEST_CLIENT_SECRET", + ), + webhook_manager=None, + default_credentials=[], + base_costs=[], + supported_auth_types={"oauth2"}, + ) + + provider2 = Provider( + name="provider2", + oauth_config=OAuthConfig( + oauth_handler=TestOAuth2, + client_id_env_var="TEST_CLIENT_ID", + client_secret_env_var="TEST_CLIENT_SECRET", + ), + webhook_manager=None, + default_credentials=[], + base_costs=[], + supported_auth_types={"oauth2"}, + ) + + AutoRegistry.register_provider(provider1) + AutoRegistry.register_provider(provider2) + + handlers = AutoRegistry.get_oauth_handlers() + assert "provider1" in handlers + assert "provider2" in handlers + assert handlers["provider1"] == TestOAuth1 + assert handlers["provider2"] == TestOAuth2 + + def test_block_configuration_registration(self): + """Test registering block configuration.""" + + # Create a test block class + class TestBlock(Block): + pass + + config = BlockConfiguration( + provider="test_provider", + costs=[], + default_credentials=[], + webhook_manager=None, + oauth_handler=None, + ) + + AutoRegistry.register_block_configuration(TestBlock, config) + + # Verify it's registered + assert TestBlock in AutoRegistry._block_configurations + assert AutoRegistry._block_configurations[TestBlock] == config + + def test_clear_registry(self): + """Test clearing all registrations.""" + # Add some registrations + provider = Provider( + name="test_provider", + oauth_handler=None, + webhook_manager=None, + default_credentials=[], + base_costs=[], + supported_auth_types={"api_key"}, + ) + AutoRegistry.register_provider(provider) + AutoRegistry.register_api_key("test", "TEST_KEY") + + # Clear everything + AutoRegistry.clear() + + # Verify everything is cleared + assert len(AutoRegistry._providers) == 0 + assert len(AutoRegistry._default_credentials) == 0 + assert len(AutoRegistry._oauth_handlers) == 0 + assert len(AutoRegistry._webhook_managers) == 0 + assert len(AutoRegistry._block_configurations) == 0 + assert len(AutoRegistry._api_key_mappings) == 0 + + +class TestAutoRegistryPatching: + """Test the integration patching functionality.""" + + def setup_method(self): + """Clear registry before each test.""" + AutoRegistry.clear() + + @patch("backend.integrations.webhooks.load_webhook_managers") + def test_webhook_manager_patching(self, mock_load_managers): + """Test that webhook managers are patched into the system.""" + # Set up the mock to return an empty dict + mock_load_managers.return_value = {} + + # Create a test webhook manager + class TestWebhookManager(BaseWebhooksManager): + PROVIDER_NAME = ProviderName.GITHUB + + # Register a provider with webhooks + provider = Provider( + name="webhook_provider", + oauth_handler=None, + webhook_manager=TestWebhookManager, + default_credentials=[], + base_costs=[], + supported_auth_types={"api_key"}, + ) + + AutoRegistry.register_provider(provider) + + # Mock the webhooks module + mock_webhooks = MagicMock() + mock_webhooks.load_webhook_managers = mock_load_managers + + with patch.dict( + "sys.modules", {"backend.integrations.webhooks": mock_webhooks} + ): + # Apply patches + AutoRegistry.patch_integrations() + + # Call the patched function + result = mock_webhooks.load_webhook_managers() + + # Verify our webhook manager is included + assert "webhook_provider" in result + assert result["webhook_provider"] == TestWebhookManager + + +class TestProviderBuilder: + """Test the ProviderBuilder fluent API.""" + + def setup_method(self): + """Clear registry before each test.""" + AutoRegistry.clear() + + def test_basic_provider_builder(self): + """Test building a basic provider.""" + provider = ( + ProviderBuilder("test_provider") + .with_api_key("TEST_API_KEY", "Test API Key") + .build() + ) + + assert provider.name == "test_provider" + assert "api_key" in provider.supported_auth_types + assert AutoRegistry.get_provider("test_provider") == provider + + def test_provider_builder_with_oauth(self): + """Test building a provider with OAuth.""" + + class TestOAuth(BaseOAuthHandler): + PROVIDER_NAME = ProviderName.GITHUB + + with patch.dict( + os.environ, + { + "OAUTH_TEST_CLIENT_ID": "test_id", + "OAUTH_TEST_CLIENT_SECRET": "test_secret", + }, + ): + provider = ( + ProviderBuilder("oauth_test") + .with_oauth(TestOAuth, scopes=["read", "write"]) + .build() + ) + + assert provider.oauth_config is not None + assert provider.oauth_config.oauth_handler == TestOAuth + assert "oauth2" in provider.supported_auth_types + + def test_provider_builder_with_webhook(self): + """Test building a provider with webhook manager.""" + + class TestWebhook(BaseWebhooksManager): + PROVIDER_NAME = ProviderName.GITHUB + + provider = ( + ProviderBuilder("webhook_test").with_webhook_manager(TestWebhook).build() + ) + + assert provider.webhook_manager == TestWebhook + + def test_provider_builder_with_base_cost(self): + """Test building a provider with base costs.""" + from backend.data.cost import BlockCostType + + provider = ( + ProviderBuilder("cost_test") + .with_base_cost(10, BlockCostType.RUN) + .with_base_cost(5, BlockCostType.BYTE) + .build() + ) + + assert len(provider.base_costs) == 2 + assert provider.base_costs[0].cost_amount == 10 + assert provider.base_costs[0].cost_type == BlockCostType.RUN + assert provider.base_costs[1].cost_amount == 5 + assert provider.base_costs[1].cost_type == BlockCostType.BYTE + + def test_provider_builder_with_api_client(self): + """Test building a provider with API client factory.""" + + def mock_client_factory(): + return Mock() + + provider = ( + ProviderBuilder("client_test").with_api_client(mock_client_factory).build() + ) + + assert provider._api_client_factory == mock_client_factory + + def test_provider_builder_with_error_handler(self): + """Test building a provider with error handler.""" + + def mock_error_handler(exc: Exception) -> str: + return f"Error: {str(exc)}" + + provider = ( + ProviderBuilder("error_test").with_error_handler(mock_error_handler).build() + ) + + assert provider._error_handler == mock_error_handler + + def test_provider_builder_complete_example(self): + """Test building a complete provider with all features.""" + from backend.data.cost import BlockCostType + + class TestOAuth(BaseOAuthHandler): + PROVIDER_NAME = ProviderName.GITHUB + + class TestWebhook(BaseWebhooksManager): + PROVIDER_NAME = ProviderName.GITHUB + + def client_factory(): + return Mock() + + def error_handler(exc): + return str(exc) + + # Set environment variables for OAuth to be registered + with patch.dict( + os.environ, + { + "COMPLETE_TEST_CLIENT_ID": "test_id", + "COMPLETE_TEST_CLIENT_SECRET": "test_secret", + "COMPLETE_API_KEY": "test_api_key", + }, + ): + provider = ( + ProviderBuilder("complete_test") + .with_api_key("COMPLETE_API_KEY", "Complete API Key") + .with_oauth(TestOAuth, scopes=["read"]) + .with_webhook_manager(TestWebhook) + .with_base_cost(100, BlockCostType.RUN) + .with_api_client(client_factory) + .with_error_handler(error_handler) + .with_config(custom_setting="value") + .build() + ) + + # Verify all settings + assert provider.name == "complete_test" + assert "api_key" in provider.supported_auth_types + assert "oauth2" in provider.supported_auth_types + assert provider.oauth_config is not None + assert provider.oauth_config.oauth_handler == TestOAuth + assert provider.webhook_manager == TestWebhook + assert len(provider.base_costs) == 1 + assert provider._api_client_factory == client_factory + assert provider._error_handler == error_handler + assert provider.get_config("custom_setting") == "value" # from with_config + + # Verify it's registered + assert AutoRegistry.get_provider("complete_test") == provider + assert "complete_test" in AutoRegistry._oauth_handlers + assert "complete_test" in AutoRegistry._webhook_managers + + +class TestSDKImports: + """Test that all expected exports are available from the SDK.""" + + def test_core_block_imports(self): + """Test core block system imports.""" + from backend.sdk import Block, BlockCategory + + # Just verify they're importable + assert Block is not None + assert BlockCategory is not None + + def test_schema_imports(self): + """Test schema and model imports.""" + from backend.sdk import APIKeyCredentials, SchemaField + + assert SchemaField is not None + assert APIKeyCredentials is not None + + def test_type_alias_imports(self): + """Test type alias imports are removed.""" + # Type aliases have been removed from SDK + # Users should import from typing or use built-in types directly + pass + + def test_cost_system_imports(self): + """Test cost system imports.""" + from backend.sdk import BlockCost, BlockCostType + + assert BlockCost is not None + assert BlockCostType is not None + + def test_utility_imports(self): + """Test utility imports.""" + from backend.sdk import BaseModel, Requests, json + + assert json is not None + assert BaseModel is not None + assert Requests is not None + + def test_integration_imports(self): + """Test integration imports.""" + from backend.sdk import ProviderName + + assert ProviderName is not None + + def test_sdk_component_imports(self): + """Test SDK-specific component imports.""" + from backend.sdk import AutoRegistry, ProviderBuilder + + assert AutoRegistry is not None + assert ProviderBuilder is not None + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/autogpt_platform/backend/test/sdk/test_sdk_webhooks.py b/autogpt_platform/backend/test/sdk/test_sdk_webhooks.py new file mode 100644 index 000000000000..65101c8fe647 --- /dev/null +++ b/autogpt_platform/backend/test/sdk/test_sdk_webhooks.py @@ -0,0 +1,509 @@ +""" +Tests for SDK webhook functionality. + +This test suite verifies webhook blocks and webhook manager integration. +""" + +from enum import Enum + +import pytest + +from backend.integrations.providers import ProviderName +from backend.sdk import ( + APIKeyCredentials, + AutoRegistry, + BaseModel, + BaseWebhooksManager, + Block, + BlockCategory, + BlockOutput, + BlockSchema, + BlockWebhookConfig, + Credentials, + CredentialsField, + CredentialsMetaInput, + Field, + ProviderBuilder, + SchemaField, + SecretStr, +) + + +class TestWebhookTypes(str, Enum): + """Test webhook event types.""" + + CREATED = "created" + UPDATED = "updated" + DELETED = "deleted" + + +class TestWebhooksManager(BaseWebhooksManager): + """Test webhook manager implementation.""" + + PROVIDER_NAME = ProviderName.GITHUB # Reuse for testing + + class WebhookType(str, Enum): + TEST = "test" + + @classmethod + async def validate_payload( + cls, webhook, request, credentials: Credentials | None = None + ): + """Validate incoming webhook payload.""" + # Mock implementation + payload = {"test": "data"} + event_type = "test_event" + return payload, event_type + + async def _register_webhook( + self, + credentials, + webhook_type: str, + resource: str, + events: list[str], + ingress_url: str, + secret: str, + ) -> tuple[str, dict]: + """Register webhook with external service.""" + # Mock implementation + webhook_id = f"test_webhook_{resource}" + config = { + "webhook_type": webhook_type, + "resource": resource, + "events": events, + "url": ingress_url, + } + return webhook_id, config + + async def _deregister_webhook(self, webhook, credentials) -> None: + """Deregister webhook from external service.""" + # Mock implementation + pass + + +class TestWebhookBlock(Block): + """Test webhook block implementation.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = CredentialsField( + provider="test_webhooks", + supported_credential_types={"api_key"}, + description="Webhook service credentials", + ) + webhook_url: str = SchemaField( + description="URL to receive webhooks", + ) + resource_id: str = SchemaField( + description="Resource to monitor", + ) + events: list[TestWebhookTypes] = SchemaField( + description="Events to listen for", + default=[TestWebhookTypes.CREATED], + ) + payload: dict = SchemaField( + description="Webhook payload", + default={}, + ) + + class Output(BlockSchema): + webhook_id: str = SchemaField(description="Registered webhook ID") + is_active: bool = SchemaField(description="Webhook is active") + event_count: int = SchemaField(description="Number of events configured") + + def __init__(self): + super().__init__( + id="test-webhook-block", + description="Test webhook block", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=TestWebhookBlock.Input, + output_schema=TestWebhookBlock.Output, + webhook_config=BlockWebhookConfig( + provider="test_webhooks", # type: ignore + webhook_type="test", + resource_format="{resource_id}", + ), + ) + + async def run( + self, input_data: Input, *, credentials: APIKeyCredentials, **kwargs + ) -> BlockOutput: + # Simulate webhook registration + webhook_id = f"webhook_{input_data.resource_id}" + + yield "webhook_id", webhook_id + yield "is_active", True + yield "event_count", len(input_data.events) + + +class TestWebhookBlockCreation: + """Test creating webhook blocks with the SDK.""" + + def setup_method(self): + """Set up test environment.""" + AutoRegistry.clear() + + # Register a provider with webhook support + self.provider = ( + ProviderBuilder("test_webhooks") + .with_api_key("TEST_WEBHOOK_KEY", "Test Webhook API Key") + .with_webhook_manager(TestWebhooksManager) + .build() + ) + + @pytest.mark.asyncio + async def test_basic_webhook_block(self): + """Test creating a basic webhook block.""" + block = TestWebhookBlock() + + # Verify block configuration + assert block.webhook_config is not None + assert block.webhook_config.provider == "test_webhooks" + assert block.webhook_config.webhook_type == "test" + assert "{resource_id}" in block.webhook_config.resource_format # type: ignore + + # Test block execution + test_creds = APIKeyCredentials( + id="test-webhook-creds", + provider="test_webhooks", + api_key=SecretStr("test-key"), + title="Test Webhook Key", + ) + + outputs = {} + async for name, value in block.run( + TestWebhookBlock.Input( + credentials={ # type: ignore + "provider": "test_webhooks", + "id": "test-webhook-creds", + "type": "api_key", + }, + webhook_url="https://example.com/webhook", + resource_id="resource_123", + events=[TestWebhookTypes.CREATED, TestWebhookTypes.UPDATED], + ), + credentials=test_creds, + ): + outputs[name] = value + + assert outputs["webhook_id"] == "webhook_resource_123" + assert outputs["is_active"] is True + assert outputs["event_count"] == 2 + + @pytest.mark.asyncio + async def test_webhook_block_with_filters(self): + """Test webhook block with event filters.""" + + class EventFilterModel(BaseModel): + include_system: bool = Field(default=False) + severity_levels: list[str] = Field( + default_factory=lambda: ["info", "warning"] + ) + + class FilteredWebhookBlock(Block): + """Webhook block with filtering.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = CredentialsField( + provider="test_webhooks", + supported_credential_types={"api_key"}, + ) + resource: str = SchemaField(description="Resource to monitor") + filters: EventFilterModel = SchemaField( + description="Event filters", + default_factory=EventFilterModel, + ) + payload: dict = SchemaField( + description="Webhook payload", + default={}, + ) + + class Output(BlockSchema): + webhook_active: bool = SchemaField(description="Webhook active") + filter_summary: str = SchemaField(description="Active filters") + + def __init__(self): + super().__init__( + id="filtered-webhook-block", + description="Webhook with filters", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=FilteredWebhookBlock.Input, + output_schema=FilteredWebhookBlock.Output, + webhook_config=BlockWebhookConfig( + provider="test_webhooks", # type: ignore + webhook_type="filtered", + resource_format="{resource}", + ), + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + filters = input_data.filters + filter_parts = [] + + if filters.include_system: + filter_parts.append("system events") + + filter_parts.append(f"{len(filters.severity_levels)} severity levels") + + yield "webhook_active", True + yield "filter_summary", ", ".join(filter_parts) + + # Test the block + block = FilteredWebhookBlock() + + test_creds = APIKeyCredentials( + id="test-creds", + provider="test_webhooks", + api_key=SecretStr("key"), + title="Test Key", + ) + + # Test with default filters + outputs = {} + async for name, value in block.run( + FilteredWebhookBlock.Input( + credentials={ # type: ignore + "provider": "test_webhooks", + "id": "test-creds", + "type": "api_key", + }, + resource="test_resource", + ), + credentials=test_creds, + ): + outputs[name] = value + + assert outputs["webhook_active"] is True + assert "2 severity levels" in outputs["filter_summary"] + + # Test with custom filters + custom_filters = EventFilterModel( + include_system=True, + severity_levels=["error", "critical"], + ) + + outputs = {} + async for name, value in block.run( + FilteredWebhookBlock.Input( + credentials={ # type: ignore + "provider": "test_webhooks", + "id": "test-creds", + "type": "api_key", + }, + resource="test_resource", + filters=custom_filters, + ), + credentials=test_creds, + ): + outputs[name] = value + + assert "system events" in outputs["filter_summary"] + assert "2 severity levels" in outputs["filter_summary"] + + +class TestWebhookManagerIntegration: + """Test webhook manager integration with AutoRegistry.""" + + def setup_method(self): + """Clear registry.""" + AutoRegistry.clear() + + def test_webhook_manager_registration(self): + """Test that webhook managers are properly registered.""" + + # Create multiple webhook managers + class WebhookManager1(BaseWebhooksManager): + PROVIDER_NAME = ProviderName.GITHUB + + class WebhookManager2(BaseWebhooksManager): + PROVIDER_NAME = ProviderName.GOOGLE + + # Register providers with webhook managers + ( + ProviderBuilder("webhook_service_1") + .with_webhook_manager(WebhookManager1) + .build() + ) + + ( + ProviderBuilder("webhook_service_2") + .with_webhook_manager(WebhookManager2) + .build() + ) + + # Verify registration + managers = AutoRegistry.get_webhook_managers() + assert "webhook_service_1" in managers + assert "webhook_service_2" in managers + assert managers["webhook_service_1"] == WebhookManager1 + assert managers["webhook_service_2"] == WebhookManager2 + + @pytest.mark.asyncio + async def test_webhook_block_with_provider_manager(self): + """Test webhook block using a provider's webhook manager.""" + # Register provider with webhook manager + ( + ProviderBuilder("integrated_webhooks") + .with_api_key("INTEGRATED_KEY", "Integrated Webhook Key") + .with_webhook_manager(TestWebhooksManager) + .build() + ) + + # Create a block that uses this provider + class IntegratedWebhookBlock(Block): + """Block using integrated webhook manager.""" + + class Input(BlockSchema): + credentials: CredentialsMetaInput = CredentialsField( + provider="integrated_webhooks", + supported_credential_types={"api_key"}, + ) + target: str = SchemaField(description="Webhook target") + payload: dict = SchemaField( + description="Webhook payload", + default={}, + ) + + class Output(BlockSchema): + status: str = SchemaField(description="Webhook status") + manager_type: str = SchemaField(description="Manager type used") + + def __init__(self): + super().__init__( + id="integrated-webhook-block", + description="Uses integrated webhook manager", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=IntegratedWebhookBlock.Input, + output_schema=IntegratedWebhookBlock.Output, + webhook_config=BlockWebhookConfig( + provider="integrated_webhooks", # type: ignore + webhook_type=TestWebhooksManager.WebhookType.TEST, + resource_format="{target}", + ), + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + # Get the webhook manager for this provider + managers = AutoRegistry.get_webhook_managers() + manager_class = managers.get("integrated_webhooks") + + yield "status", "configured" + yield "manager_type", ( + manager_class.__name__ if manager_class else "none" + ) + + # Test the block + block = IntegratedWebhookBlock() + + test_creds = APIKeyCredentials( + id="integrated-creds", + provider="integrated_webhooks", + api_key=SecretStr("key"), + title="Integrated Key", + ) + + outputs = {} + async for name, value in block.run( + IntegratedWebhookBlock.Input( + credentials={ # type: ignore + "provider": "integrated_webhooks", + "id": "integrated-creds", + "type": "api_key", + }, + target="test_target", + ), + credentials=test_creds, + ): + outputs[name] = value + + assert outputs["status"] == "configured" + assert outputs["manager_type"] == "TestWebhooksManager" + + +class TestWebhookEventHandling: + """Test webhook event handling in blocks.""" + + @pytest.mark.asyncio + async def test_webhook_event_processing_block(self): + """Test a block that processes webhook events.""" + + class WebhookEventBlock(Block): + """Block that processes webhook events.""" + + class Input(BlockSchema): + event_type: str = SchemaField(description="Type of webhook event") + payload: dict = SchemaField(description="Webhook payload") + verify_signature: bool = SchemaField( + description="Whether to verify webhook signature", + default=True, + ) + + class Output(BlockSchema): + processed: bool = SchemaField(description="Event was processed") + event_summary: str = SchemaField(description="Summary of event") + action_required: bool = SchemaField(description="Action required") + + def __init__(self): + super().__init__( + id="webhook-event-processor", + description="Processes incoming webhook events", + categories={BlockCategory.DEVELOPER_TOOLS}, + input_schema=WebhookEventBlock.Input, + output_schema=WebhookEventBlock.Output, + ) + + async def run(self, input_data: Input, **kwargs) -> BlockOutput: + # Process based on event type + event_type = input_data.event_type + payload = input_data.payload + + if event_type == "created": + summary = f"New item created: {payload.get('id', 'unknown')}" + action_required = True + elif event_type == "updated": + summary = f"Item updated: {payload.get('id', 'unknown')}" + action_required = False + elif event_type == "deleted": + summary = f"Item deleted: {payload.get('id', 'unknown')}" + action_required = True + else: + summary = f"Unknown event: {event_type}" + action_required = False + + yield "processed", True + yield "event_summary", summary + yield "action_required", action_required + + # Test the block with different events + block = WebhookEventBlock() + + # Test created event + outputs = {} + async for name, value in block.run( + WebhookEventBlock.Input( + event_type="created", + payload={"id": "123", "name": "Test Item"}, + ) + ): + outputs[name] = value + + assert outputs["processed"] is True + assert "New item created: 123" in outputs["event_summary"] + assert outputs["action_required"] is True + + # Test updated event + outputs = {} + async for name, value in block.run( + WebhookEventBlock.Input( + event_type="updated", + payload={"id": "456", "changes": ["name", "status"]}, + ) + ): + outputs[name] = value + + assert outputs["processed"] is True + assert "Item updated: 456" in outputs["event_summary"] + assert outputs["action_required"] is False + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/autogpt_platform/backend/test/server/test_con_manager.py b/autogpt_platform/backend/test/server/test_con_manager.py deleted file mode 100644 index 80f9e08f5a48..000000000000 --- a/autogpt_platform/backend/test/server/test_con_manager.py +++ /dev/null @@ -1,118 +0,0 @@ -from datetime import datetime, timezone -from unittest.mock import AsyncMock - -import pytest -from fastapi import WebSocket - -from backend.data.execution import ExecutionResult, ExecutionStatus -from backend.server.conn_manager import ConnectionManager -from backend.server.model import Methods, WsMessage - - -@pytest.fixture -def connection_manager() -> ConnectionManager: - return ConnectionManager() - - -@pytest.fixture -def mock_websocket() -> AsyncMock: - websocket: AsyncMock = AsyncMock(spec=WebSocket) - websocket.send_text = AsyncMock() - return websocket - - -@pytest.mark.asyncio -async def test_connect( - connection_manager: ConnectionManager, mock_websocket: AsyncMock -) -> None: - await connection_manager.connect(mock_websocket) - assert mock_websocket in connection_manager.active_connections - mock_websocket.accept.assert_called_once() - - -def test_disconnect( - connection_manager: ConnectionManager, mock_websocket: AsyncMock -) -> None: - connection_manager.active_connections.add(mock_websocket) - connection_manager.subscriptions["test_graph"] = {mock_websocket} - - connection_manager.disconnect(mock_websocket) - - assert mock_websocket not in connection_manager.active_connections - assert mock_websocket not in connection_manager.subscriptions["test_graph"] - - -@pytest.mark.asyncio -async def test_subscribe( - connection_manager: ConnectionManager, mock_websocket: AsyncMock -) -> None: - await connection_manager.subscribe("test_graph", mock_websocket) - assert mock_websocket in connection_manager.subscriptions["test_graph"] - - -@pytest.mark.asyncio -async def test_unsubscribe( - connection_manager: ConnectionManager, mock_websocket: AsyncMock -) -> None: - connection_manager.subscriptions["test_graph"] = {mock_websocket} - - await connection_manager.unsubscribe("test_graph", mock_websocket) - - assert "test_graph" not in connection_manager.subscriptions - - -@pytest.mark.asyncio -async def test_send_execution_result( - connection_manager: ConnectionManager, mock_websocket: AsyncMock -) -> None: - connection_manager.subscriptions["test_graph"] = {mock_websocket} - result: ExecutionResult = ExecutionResult( - graph_id="test_graph", - graph_version=1, - graph_exec_id="test_exec_id", - node_exec_id="test_node_exec_id", - node_id="test_node_id", - block_id="test_block_id", - status=ExecutionStatus.COMPLETED, - input_data={"input1": "value1"}, - output_data={"output1": ["result1"]}, - add_time=datetime.now(tz=timezone.utc), - queue_time=None, - start_time=datetime.now(tz=timezone.utc), - end_time=datetime.now(tz=timezone.utc), - ) - - await connection_manager.send_execution_result(result) - - mock_websocket.send_text.assert_called_once_with( - WsMessage( - method=Methods.EXECUTION_EVENT, - channel="test_graph", - data=result.model_dump(), - ).model_dump_json() - ) - - -@pytest.mark.asyncio -async def test_send_execution_result_no_subscribers( - connection_manager: ConnectionManager, mock_websocket: AsyncMock -) -> None: - result: ExecutionResult = ExecutionResult( - graph_id="test_graph", - graph_version=1, - graph_exec_id="test_exec_id", - node_exec_id="test_node_exec_id", - node_id="test_node_id", - block_id="test_block_id", - status=ExecutionStatus.COMPLETED, - input_data={"input1": "value1"}, - output_data={"output1": ["result1"]}, - add_time=datetime.now(), - queue_time=None, - start_time=datetime.now(), - end_time=datetime.now(), - ) - - await connection_manager.send_execution_result(result) - - mock_websocket.send_text.assert_not_called() diff --git a/autogpt_platform/backend/test/server/test_ws_api.py b/autogpt_platform/backend/test/server/test_ws_api.py deleted file mode 100644 index cedcc935b404..000000000000 --- a/autogpt_platform/backend/test/server/test_ws_api.py +++ /dev/null @@ -1,154 +0,0 @@ -from typing import cast -from unittest.mock import AsyncMock - -import pytest -from fastapi import WebSocket, WebSocketDisconnect - -from backend.server.conn_manager import ConnectionManager -from backend.server.ws_api import ( - Methods, - WsMessage, - handle_subscribe, - handle_unsubscribe, - websocket_router, -) - - -@pytest.fixture -def mock_websocket() -> AsyncMock: - return AsyncMock(spec=WebSocket) - - -@pytest.fixture -def mock_manager() -> AsyncMock: - return AsyncMock(spec=ConnectionManager) - - -@pytest.mark.asyncio -async def test_websocket_router_subscribe( - mock_websocket: AsyncMock, mock_manager: AsyncMock -) -> None: - mock_websocket.receive_text.side_effect = [ - WsMessage( - method=Methods.SUBSCRIBE, data={"graph_id": "test_graph"} - ).model_dump_json(), - WebSocketDisconnect(), - ] - - await websocket_router( - cast(WebSocket, mock_websocket), cast(ConnectionManager, mock_manager) - ) - - mock_manager.connect.assert_called_once_with(mock_websocket) - mock_manager.subscribe.assert_called_once_with("test_graph", mock_websocket) - mock_websocket.send_text.assert_called_once() - assert '"method":"subscribe"' in mock_websocket.send_text.call_args[0][0] - assert '"success":true' in mock_websocket.send_text.call_args[0][0] - mock_manager.disconnect.assert_called_once_with(mock_websocket) - - -@pytest.mark.asyncio -async def test_websocket_router_unsubscribe( - mock_websocket: AsyncMock, mock_manager: AsyncMock -) -> None: - mock_websocket.receive_text.side_effect = [ - WsMessage( - method=Methods.UNSUBSCRIBE, data={"graph_id": "test_graph"} - ).model_dump_json(), - WebSocketDisconnect(), - ] - - await websocket_router( - cast(WebSocket, mock_websocket), cast(ConnectionManager, mock_manager) - ) - - mock_manager.connect.assert_called_once_with(mock_websocket) - mock_manager.unsubscribe.assert_called_once_with("test_graph", mock_websocket) - mock_websocket.send_text.assert_called_once() - assert '"method":"unsubscribe"' in mock_websocket.send_text.call_args[0][0] - assert '"success":true' in mock_websocket.send_text.call_args[0][0] - mock_manager.disconnect.assert_called_once_with(mock_websocket) - - -@pytest.mark.asyncio -async def test_websocket_router_invalid_method( - mock_websocket: AsyncMock, mock_manager: AsyncMock -) -> None: - mock_websocket.receive_text.side_effect = [ - WsMessage(method=Methods.EXECUTION_EVENT).model_dump_json(), - WebSocketDisconnect(), - ] - - await websocket_router( - cast(WebSocket, mock_websocket), cast(ConnectionManager, mock_manager) - ) - - mock_manager.connect.assert_called_once_with(mock_websocket) - mock_websocket.send_text.assert_called_once() - assert '"method":"error"' in mock_websocket.send_text.call_args[0][0] - assert '"success":false' in mock_websocket.send_text.call_args[0][0] - mock_manager.disconnect.assert_called_once_with(mock_websocket) - - -@pytest.mark.asyncio -async def test_handle_subscribe_success( - mock_websocket: AsyncMock, mock_manager: AsyncMock -) -> None: - message = WsMessage(method=Methods.SUBSCRIBE, data={"graph_id": "test_graph"}) - - await handle_subscribe( - cast(WebSocket, mock_websocket), cast(ConnectionManager, mock_manager), message - ) - - mock_manager.subscribe.assert_called_once_with("test_graph", mock_websocket) - mock_websocket.send_text.assert_called_once() - assert '"method":"subscribe"' in mock_websocket.send_text.call_args[0][0] - assert '"success":true' in mock_websocket.send_text.call_args[0][0] - - -@pytest.mark.asyncio -async def test_handle_subscribe_missing_data( - mock_websocket: AsyncMock, mock_manager: AsyncMock -) -> None: - message = WsMessage(method=Methods.SUBSCRIBE) - - await handle_subscribe( - cast(WebSocket, mock_websocket), cast(ConnectionManager, mock_manager), message - ) - - mock_manager.subscribe.assert_not_called() - mock_websocket.send_text.assert_called_once() - assert '"method":"error"' in mock_websocket.send_text.call_args[0][0] - assert '"success":false' in mock_websocket.send_text.call_args[0][0] - - -@pytest.mark.asyncio -async def test_handle_unsubscribe_success( - mock_websocket: AsyncMock, mock_manager: AsyncMock -) -> None: - message = WsMessage(method=Methods.UNSUBSCRIBE, data={"graph_id": "test_graph"}) - - await handle_unsubscribe( - cast(WebSocket, mock_websocket), cast(ConnectionManager, mock_manager), message - ) - - mock_manager.unsubscribe.assert_called_once_with("test_graph", mock_websocket) - mock_websocket.send_text.assert_called_once() - assert '"method":"unsubscribe"' in mock_websocket.send_text.call_args[0][0] - assert '"success":true' in mock_websocket.send_text.call_args[0][0] - - -@pytest.mark.asyncio -async def test_handle_unsubscribe_missing_data( - mock_websocket: AsyncMock, mock_manager: AsyncMock -) -> None: - message = WsMessage(method=Methods.UNSUBSCRIBE) - - await handle_unsubscribe( - cast(WebSocket, mock_websocket), cast(ConnectionManager, mock_manager), message - ) - - mock_manager.unsubscribe.assert_not_called() - mock_websocket.send_text.assert_called_once() - assert '"method":"error"' in mock_websocket.send_text.call_args[0][0] - assert '"success":false' in mock_websocket.send_text.call_args[0][0] diff --git a/autogpt_platform/backend/test/test_data_creator.py b/autogpt_platform/backend/test/test_data_creator.py index 1f79386cc6e7..7901ac7574ce 100644 --- a/autogpt_platform/backend/test/test_data_creator.py +++ b/autogpt_platform/backend/test/test_data_creator.py @@ -1,10 +1,42 @@ +""" +Test Data Creator for AutoGPT Platform + +This script creates test data for the AutoGPT platform database. + +Image/Video URL Domains Used: +- Images: picsum.photos (for all image URLs - avatars, store listing images, etc.) +- Videos: youtube.com (for store listing video URLs) + +Add these domains to your Next.js config: +```javascript +// next.config.js +images: { + domains: ['picsum.photos'], +} +``` +""" + import asyncio import random from datetime import datetime import prisma.enums from faker import Faker -from prisma import Prisma +from prisma import Json, Prisma +from prisma.types import ( + AgentBlockCreateInput, + AgentGraphCreateInput, + AgentNodeCreateInput, + AgentNodeLinkCreateInput, + AnalyticsDetailsCreateInput, + AnalyticsMetricsCreateInput, + APIKeyCreateInput, + CreditTransactionCreateInput, + IntegrationWebhookCreateInput, + ProfileCreateInput, + StoreListingReviewCreateInput, + UserCreateInput, +) faker = Faker() @@ -40,10 +72,26 @@ def get_image(): - url = faker.image_url() - while "placekitten.com" in url: - url = faker.image_url() - return url + """Generate a consistent image URL using picsum.photos service.""" + width = random.choice([200, 300, 400, 500, 600, 800]) + height = random.choice([200, 300, 400, 500, 600, 800]) + # Use a random seed to get different images + seed = random.randint(1, 1000) + return f"https://picsum.photos/seed/{seed}/{width}/{height}" + + +def get_video_url(): + """Generate a consistent video URL using a placeholder service.""" + # Using YouTube as a consistent source for video URLs + video_ids = [ + "dQw4w9WgXcQ", # Example video IDs + "9bZkp7q19f0", + "kJQP7kiw5Fk", + "RgKAFK5djSk", + "L_jWHffIx5E", + ] + video_id = random.choice(video_ids) + return f"https://www.youtube.com/watch?v={video_id}" async def main(): @@ -55,13 +103,13 @@ async def main(): users = [] for _ in range(NUM_USERS): user = await db.user.create( - data={ - "id": str(faker.uuid4()), - "email": faker.unique.email(), - "name": faker.name(), - "metadata": prisma.Json({}), - "integrations": "", - } + data=UserCreateInput( + id=str(faker.uuid4()), + email=faker.unique.email(), + name=faker.name(), + metadata=prisma.Json({}), + integrations="", + ) ) users.append(user) @@ -70,11 +118,11 @@ async def main(): print(f"Inserting {NUM_AGENT_BLOCKS} agent blocks") for _ in range(NUM_AGENT_BLOCKS): block = await db.agentblock.create( - data={ - "name": f"{faker.word()}_{str(faker.uuid4())[:8]}", - "inputSchema": "{}", - "outputSchema": "{}", - } + data=AgentBlockCreateInput( + name=f"{faker.word()}_{str(faker.uuid4())[:8]}", + inputSchema="{}", + outputSchema="{}", + ) ) agent_blocks.append(block) @@ -86,13 +134,12 @@ async def main(): random.randint(MIN_GRAPHS_PER_USER, MAX_GRAPHS_PER_USER) ): # Adjust the range to create more graphs per user if desired graph = await db.agentgraph.create( - data={ - "name": faker.sentence(nb_words=3), - "description": faker.text(max_nb_chars=200), - "userId": user.id, - "isActive": True, - "isTemplate": False, - } + data=AgentGraphCreateInput( + name=faker.sentence(nb_words=3), + description=faker.text(max_nb_chars=200), + userId=user.id, + isActive=True, + ) ) agent_graphs.append(graph) @@ -106,13 +153,13 @@ async def main(): for _ in range(num_nodes): # Create 5 AgentNodes per graph block = random.choice(agent_blocks) node = await db.agentnode.create( - data={ - "agentBlockId": block.id, - "agentGraphId": graph.id, - "agentGraphVersion": graph.version, - "constantInput": "{}", - "metadata": "{}", - } + data=AgentNodeCreateInput( + agentBlockId=block.id, + agentGraphId=graph.id, + agentGraphVersion=graph.version, + constantInput=Json({}), + metadata=Json({}), + ) ) agent_nodes.append(node) @@ -128,36 +175,65 @@ async def main(): "name": faker.sentence(nb_words=3), "description": faker.text(max_nb_chars=200), "userId": user.id, - "agentId": graph.id, - "agentVersion": graph.version, + "agentGraphId": graph.id, + "agentGraphVersion": graph.version, "isActive": True, } ) agent_presets.append(preset) - # Insert UserAgents - user_agents = [] - print(f"Inserting {NUM_USERS * MAX_AGENTS_PER_USER} user agents") + # Insert Profiles first (before LibraryAgents) + profiles = [] + print(f"Inserting {NUM_USERS} profiles") + for user in users: + profile = await db.profile.create( + data=ProfileCreateInput( + userId=user.id, + name=user.name or faker.name(), + username=faker.unique.user_name(), + description=faker.text(), + links=[faker.url() for _ in range(3)], + avatarUrl=get_image(), + ) + ) + profiles.append(profile) + + # Insert LibraryAgents + library_agents = [] + print("Inserting library agents") for user in users: num_agents = random.randint(MIN_AGENTS_PER_USER, MAX_AGENTS_PER_USER) - for _ in range(num_agents): # Create 1 UserAgent per user - graph = random.choice(agent_graphs) - preset = random.choice(agent_presets) - user_agent = await db.useragent.create( + # Get a shuffled list of graphs to ensure uniqueness per user + available_graphs = agent_graphs.copy() + random.shuffle(available_graphs) + + # Limit to available unique graphs + num_agents = min(num_agents, len(available_graphs)) + + for i in range(num_agents): + graph = available_graphs[i] # Use unique graph for each library agent + + # Get creator profile for this graph's owner + creator_profile = next( + (p for p in profiles if p.userId == graph.userId), None + ) + + library_agent = await db.libraryagent.create( data={ "userId": user.id, - "agentId": graph.id, - "agentVersion": graph.version, - "agentPresetId": preset.id, + "agentGraphId": graph.id, + "agentGraphVersion": graph.version, + "creatorId": creator_profile.id if creator_profile else None, + "imageUrl": get_image() if random.random() < 0.5 else None, + "useGraphIsActiveVersion": random.choice([True, False]), "isFavorite": random.choice([True, False]), "isCreatedByUser": random.choice([True, False]), "isArchived": random.choice([True, False]), "isDeleted": random.choice([True, False]), } ) - user_agents.append(user_agent) + library_agents.append(library_agent) - # Insert AgentGraphExecutions # Insert AgentGraphExecutions agent_graph_executions = [] print( @@ -170,7 +246,7 @@ async def main(): MIN_EXECUTIONS_PER_GRAPH, MAX_EXECUTIONS_PER_GRAPH ) for _ in range(num_executions): - matching_presets = [p for p in agent_presets if p.agentId == graph.id] + matching_presets = [p for p in agent_presets if p.agentGraphId == graph.id] preset = ( random.choice(matching_presets) if matching_presets and random.random() < 0.5 @@ -254,13 +330,13 @@ async def main(): source_node = nodes[0] sink_node = nodes[1] await db.agentnodelink.create( - data={ - "agentNodeSourceId": source_node.id, - "sourceName": "output1", - "agentNodeSinkId": sink_node.id, - "sinkName": "input1", - "isStatic": False, - } + data=AgentNodeLinkCreateInput( + agentNodeSourceId=source_node.id, + sourceName="output1", + agentNodeSinkId=sink_node.id, + sinkName="input1", + isStatic=False, + ) ) # Insert AnalyticsDetails @@ -268,12 +344,12 @@ async def main(): for user in users: for _ in range(1): await db.analyticsdetails.create( - data={ - "userId": user.id, - "type": faker.word(), - "data": prisma.Json({}), - "dataIndex": faker.word(), - } + data=AnalyticsDetailsCreateInput( + userId=user.id, + type=faker.word(), + data=prisma.Json({}), + dataIndex=faker.word(), + ) ) # Insert AnalyticsMetrics @@ -281,12 +357,12 @@ async def main(): for user in users: for _ in range(1): await db.analyticsmetrics.create( - data={ - "userId": user.id, - "analyticMetric": faker.word(), - "value": random.uniform(0, 100), - "dataString": faker.word(), - } + data=AnalyticsMetricsCreateInput( + userId=user.id, + analyticMetric=faker.word(), + value=random.uniform(0, 100), + dataString=faker.word(), + ) ) # Insert CreditTransaction (formerly UserBlockCredit) @@ -295,77 +371,67 @@ async def main(): for _ in range(1): block = random.choice(agent_blocks) await db.credittransaction.create( - data={ - "transactionKey": str(faker.uuid4()), - "userId": user.id, - "blockId": block.id, - "amount": random.randint(1, 100), - "type": ( + data=CreditTransactionCreateInput( + transactionKey=str(faker.uuid4()), + userId=user.id, + amount=random.randint(1, 100), + type=( prisma.enums.CreditTransactionType.TOP_UP if random.random() < 0.5 else prisma.enums.CreditTransactionType.USAGE ), - "metadata": prisma.Json({}), - } + metadata=prisma.Json({}), + ) ) - # Insert Profiles - profiles = [] - print(f"Inserting {NUM_USERS} profiles") - for user in users: - profile = await db.profile.create( - data={ - "userId": user.id, - "name": user.name or faker.name(), - "username": faker.unique.user_name(), - "description": faker.text(), - "links": [faker.url() for _ in range(3)], - "avatarUrl": get_image(), - } - ) - profiles.append(profile) - # Insert StoreListings store_listings = [] - print(f"Inserting {NUM_USERS} store listings") + print("Inserting store listings") for graph in agent_graphs: user = random.choice(users) + slug = faker.slug() listing = await db.storelisting.create( data={ - "agentId": graph.id, - "agentVersion": graph.version, + "agentGraphId": graph.id, + "agentGraphVersion": graph.version, "owningUserId": user.id, - "isApproved": random.choice([True, False]), + "hasApprovedVersion": random.choice([True, False]), + "slug": slug, } ) store_listings.append(listing) # Insert StoreListingVersions store_listing_versions = [] - print(f"Inserting {NUM_USERS} store listing versions") + print("Inserting store listing versions") for listing in store_listings: - graph = [g for g in agent_graphs if g.id == listing.agentId][0] + graph = [g for g in agent_graphs if g.id == listing.agentGraphId][0] version = await db.storelistingversion.create( data={ - "agentId": graph.id, - "agentVersion": graph.version, - "slug": faker.slug(), + "agentGraphId": graph.id, + "agentGraphVersion": graph.version, "name": graph.name or faker.sentence(nb_words=3), "subHeading": faker.sentence(), - "videoUrl": faker.url(), + "videoUrl": get_video_url() if random.random() < 0.3 else None, "imageUrls": [get_image() for _ in range(3)], "description": faker.text(), "categories": [faker.word() for _ in range(3)], "isFeatured": random.choice([True, False]), "isAvailable": True, - "isApproved": random.choice([True, False]), "storeListingId": listing.id, + "submissionStatus": random.choice( + [ + prisma.enums.SubmissionStatus.PENDING, + prisma.enums.SubmissionStatus.APPROVED, + prisma.enums.SubmissionStatus.REJECTED, + ] + ), } ) store_listing_versions.append(version) # Insert StoreListingReviews - print(f"Inserting {NUM_USERS * MAX_REVIEWS_PER_VERSION} store listing reviews") + print("Inserting store listing reviews") for version in store_listing_versions: # Create a copy of users list and shuffle it to avoid duplicates available_reviewers = users.copy() @@ -380,56 +446,126 @@ async def main(): # Take only the first num_reviews reviewers for reviewer in available_reviewers[:num_reviews]: await db.storelistingreview.create( + data=StoreListingReviewCreateInput( + storeListingVersionId=version.id, + reviewByUserId=reviewer.id, + score=random.randint(1, 5), + comments=faker.text(), + ) + ) + + # Insert UserOnboarding for some users + print("Inserting user onboarding data") + for user in random.sample( + users, k=int(NUM_USERS * 0.7) + ): # 70% of users have onboarding data + completed_steps = [] + possible_steps = list(prisma.enums.OnboardingStep) + # Randomly complete some steps + if random.random() < 0.8: + num_steps = random.randint(1, len(possible_steps)) + completed_steps = random.sample(possible_steps, k=num_steps) + + try: + await db.useronboarding.create( + data={ + "userId": user.id, + "completedSteps": completed_steps, + "notificationDot": random.choice([True, False]), + "notified": ( + random.sample(completed_steps, k=min(3, len(completed_steps))) + if completed_steps + else [] + ), + "rewardedFor": ( + random.sample(completed_steps, k=min(2, len(completed_steps))) + if completed_steps + else [] + ), + "usageReason": ( + random.choice(["personal", "business", "research", "learning"]) + if random.random() < 0.7 + else None + ), + "integrations": random.sample( + ["github", "google", "discord", "slack"], k=random.randint(0, 2) + ), + "otherIntegrations": ( + faker.word() if random.random() < 0.2 else None + ), + "selectedStoreListingVersionId": ( + random.choice(store_listing_versions).id + if store_listing_versions and random.random() < 0.5 + else None + ), + "agentInput": ( + Json({"test": "data"}) if random.random() < 0.3 else None + ), + "onboardingAgentExecutionId": ( + random.choice(agent_graph_executions).id + if agent_graph_executions and random.random() < 0.3 + else None + ), + "agentRuns": random.randint(0, 10), + } + ) + except Exception as e: + print(f"Error creating onboarding for user {user.id}: {e}") + # Try simpler version + await db.useronboarding.create( data={ - "storeListingVersionId": version.id, - "reviewByUserId": reviewer.id, - "score": random.randint(1, 5), - "comments": faker.text(), + "userId": user.id, } ) - # Insert StoreListingSubmissions - print(f"Inserting {NUM_USERS} store listing submissions") - for listing in store_listings: - version = random.choice(store_listing_versions) - reviewer = random.choice(users) - status: prisma.enums.SubmissionStatus = random.choice( - [ - prisma.enums.SubmissionStatus.PENDING, - prisma.enums.SubmissionStatus.APPROVED, - prisma.enums.SubmissionStatus.REJECTED, - ] - ) - await db.storelistingsubmission.create( - data={ - "storeListingId": listing.id, - "storeListingVersionId": version.id, - "reviewerId": reviewer.id, - "Status": status, - "reviewComments": faker.text(), - } - ) + # Insert IntegrationWebhooks for some users + print("Inserting integration webhooks") + for user in random.sample( + users, k=int(NUM_USERS * 0.3) + ): # 30% of users have webhooks + for _ in range(random.randint(1, 3)): + await db.integrationwebhook.create( + data=IntegrationWebhookCreateInput( + userId=user.id, + provider=random.choice(["github", "slack", "discord"]), + credentialsId=str(faker.uuid4()), + webhookType=random.choice(["repo", "channel", "server"]), + resource=faker.slug(), + events=[ + random.choice(["created", "updated", "deleted"]) + for _ in range(random.randint(1, 3)) + ], + config=prisma.Json({"url": faker.url()}), + secret=str(faker.sha256()), + providerWebhookId=str(faker.uuid4()), + ) + ) # Insert APIKeys print(f"Inserting {NUM_USERS} api keys") for user in users: await db.apikey.create( - data={ - "name": faker.word(), - "prefix": str(faker.uuid4())[:8], - "postfix": str(faker.uuid4())[-8:], - "key": str(faker.sha256()), - "status": prisma.enums.APIKeyStatus.ACTIVE, - "permissions": [ + data=APIKeyCreateInput( + name=faker.word(), + prefix=str(faker.uuid4())[:8], + postfix=str(faker.uuid4())[-8:], + key=str(faker.sha256()), + status=prisma.enums.APIKeyStatus.ACTIVE, + permissions=[ prisma.enums.APIKeyPermission.EXECUTE_GRAPH, prisma.enums.APIKeyPermission.READ_GRAPH, ], - "description": faker.text(), - "userId": user.id, - } + description=faker.text(), + userId=user.id, + ) ) + # Refresh materialized views + print("Refreshing materialized views...") + await db.execute_raw("SELECT refresh_store_materialized_views();") + await db.disconnect() + print("Test data creation completed successfully!") if __name__ == "__main__": diff --git a/autogpt_platform/backend/test/test_data_updater.py b/autogpt_platform/backend/test/test_data_updater.py new file mode 100755 index 000000000000..561a5da21a6b --- /dev/null +++ b/autogpt_platform/backend/test/test_data_updater.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +""" +Test Data Updater for Store Materialized Views + +This script updates existing test data to trigger changes in the materialized views: +- mv_agent_run_counts: Updated by creating new AgentGraphExecution records +- mv_review_stats: Updated by creating new StoreListingReview records + +Run this after test_data_creator.py to test that materialized views update correctly. +""" + +import asyncio +import random +from datetime import datetime, timedelta + +import prisma.enums +from faker import Faker +from prisma import Json, Prisma + +faker = Faker() + + +async def main(): + db = Prisma() + await db.connect() + + print("Starting test data updates for materialized views...") + print("=" * 60) + + # Get existing data + users = await db.user.find_many(take=50) + agent_graphs = await db.agentgraph.find_many(where={"isActive": True}, take=50) + store_listings = await db.storelisting.find_many( + where={"hasApprovedVersion": True}, include={"Versions": True}, take=30 + ) + agent_nodes = await db.agentnode.find_many(take=100) + + if not all([users, agent_graphs, store_listings]): + print( + "ERROR: Not enough test data found. Please run test_data_creator.py first." + ) + await db.disconnect() + return + + print( + f"Found {len(users)} users, {len(agent_graphs)} graphs, {len(store_listings)} store listings" + ) + print() + + # 1. Add new AgentGraphExecutions to update mv_agent_run_counts + print("1. Adding new agent graph executions...") + print("-" * 40) + + new_executions_count = 0 + execution_data = [] + + for graph in random.sample(agent_graphs, min(20, len(agent_graphs))): + # Add 5-15 new executions per selected graph + num_new_executions = random.randint(5, 15) + for _ in range(num_new_executions): + user = random.choice(users) + execution_data.append( + { + "agentGraphId": graph.id, + "agentGraphVersion": graph.version, + "userId": user.id, + "executionStatus": random.choice( + [ + prisma.enums.AgentExecutionStatus.COMPLETED, + prisma.enums.AgentExecutionStatus.FAILED, + prisma.enums.AgentExecutionStatus.RUNNING, + ] + ), + "startedAt": faker.date_time_between( + start_date="-7d", end_date="now" + ), + "stats": Json( + { + "duration": random.randint(100, 5000), + "blocks_executed": random.randint(1, 10), + } + ), + } + ) + new_executions_count += 1 + + # Batch create executions + await db.agentgraphexecution.create_many(data=execution_data) + print(f"✓ Created {new_executions_count} new executions") + + # Get the created executions for node executions + recent_executions = await db.agentgraphexecution.find_many( + take=new_executions_count, order={"createdAt": "desc"} + ) + + # 2. Add corresponding AgentNodeExecutions + print("\n2. Adding agent node executions...") + print("-" * 40) + + node_execution_data = [] + for execution in recent_executions: + # Get nodes for this graph + graph_nodes = [ + n for n in agent_nodes if n.agentGraphId == execution.agentGraphId + ] + if graph_nodes: + for node in random.sample(graph_nodes, min(3, len(graph_nodes))): + node_execution_data.append( + { + "agentGraphExecutionId": execution.id, + "agentNodeId": node.id, + "executionStatus": execution.executionStatus, + "addedTime": datetime.now(), + "startedTime": datetime.now() + - timedelta(minutes=random.randint(1, 10)), + "endedTime": ( + datetime.now() + if execution.executionStatus + == prisma.enums.AgentExecutionStatus.COMPLETED + else None + ), + } + ) + + await db.agentnodeexecution.create_many(data=node_execution_data) + print(f"✓ Created {len(node_execution_data)} node executions") + + # 3. Add new StoreListingReviews to update mv_review_stats + print("\n3. Adding new store listing reviews...") + print("-" * 40) + + new_reviews_count = 0 + + for listing in store_listings: + if not listing.Versions: + continue + + # Get approved versions + approved_versions = [ + v + for v in listing.Versions + if v.submissionStatus == prisma.enums.SubmissionStatus.APPROVED + ] + if not approved_versions: + continue + + # Pick a version to add reviews to + version = random.choice(approved_versions) + + # Get existing reviews for this version to avoid duplicates + existing_reviews = await db.storelistingreview.find_many( + where={"storeListingVersionId": version.id} + ) + existing_reviewer_ids = {r.reviewByUserId for r in existing_reviews} + + # Find users who haven't reviewed this version yet + available_reviewers = [u for u in users if u.id not in existing_reviewer_ids] + + if available_reviewers: + # Add 2-5 new reviews + num_new_reviews = min(random.randint(2, 5), len(available_reviewers)) + selected_reviewers = random.sample(available_reviewers, num_new_reviews) + + for reviewer in selected_reviewers: + # Bias towards positive reviews (4-5 stars) + score = random.choices([1, 2, 3, 4, 5], weights=[5, 10, 20, 40, 25])[0] + + await db.storelistingreview.create( + data={ + "storeListingVersionId": version.id, + "reviewByUserId": reviewer.id, + "score": score, + "comments": ( + faker.text(max_nb_chars=200) + if random.random() < 0.7 + else None + ), + } + ) + new_reviews_count += 1 + + print(f"✓ Created {new_reviews_count} new reviews") + + # 4. Update some store listing versions (change categories, featured status) + print("\n4. Updating store listing versions...") + print("-" * 40) + + updates_count = 0 + for listing in random.sample(store_listings, min(10, len(store_listings))): + if listing.Versions: + version = random.choice(listing.Versions) + if version.submissionStatus == prisma.enums.SubmissionStatus.APPROVED: + # Toggle featured status or update categories + new_categories = random.sample( + [ + "productivity", + "ai", + "automation", + "data", + "social", + "marketing", + "development", + "analytics", + ], + k=random.randint(2, 4), + ) + + await db.storelistingversion.update( + where={"id": version.id}, + data={ + "isFeatured": ( + not version.isFeatured + if random.random() < 0.3 + else version.isFeatured + ), + "categories": new_categories, + "updatedAt": datetime.now(), + }, + ) + updates_count += 1 + + print(f"✓ Updated {updates_count} store listing versions") + + # 5. Create some new credit transactions + print("\n5. Adding credit transactions...") + print("-" * 40) + + transaction_count = 0 + for user in random.sample(users, min(30, len(users))): + # Add 1-3 transactions per user + for _ in range(random.randint(1, 3)): + transaction_type = random.choice( + [ + prisma.enums.CreditTransactionType.USAGE, + prisma.enums.CreditTransactionType.TOP_UP, + prisma.enums.CreditTransactionType.GRANT, + ] + ) + + amount = ( + random.randint(10, 500) + if transaction_type == prisma.enums.CreditTransactionType.TOP_UP + else -random.randint(1, 50) + ) + + await db.credittransaction.create( + data={ + "userId": user.id, + "amount": amount, + "type": transaction_type, + "metadata": Json( + { + "source": "test_updater", + "timestamp": datetime.now().isoformat(), + } + ), + } + ) + transaction_count += 1 + + print(f"✓ Created {transaction_count} credit transactions") + + # 6. Refresh materialized views + print("\n6. Refreshing materialized views...") + print("-" * 40) + + try: + await db.execute_raw("SELECT refresh_store_materialized_views();") + print("✓ Materialized views refreshed successfully") + except Exception as e: + print(f"⚠ Warning: Could not refresh materialized views: {e}") + print( + " You may need to refresh them manually with: SELECT refresh_store_materialized_views();" + ) + + # 7. Verify the updates + print("\n7. Verifying updates...") + print("-" * 40) + + # Check agent run counts + run_counts = await db.query_raw( + "SELECT COUNT(*) as view_count FROM mv_agent_run_counts" + ) + print(f"✓ mv_agent_run_counts has {run_counts[0]['view_count']} entries") + + # Check review stats + review_stats = await db.query_raw( + "SELECT COUNT(*) as view_count FROM mv_review_stats" + ) + print(f"✓ mv_review_stats has {review_stats[0]['view_count']} entries") + + # Sample some data from the views + print("\nSample data from materialized views:") + + sample_runs = await db.query_raw( + "SELECT * FROM mv_agent_run_counts ORDER BY run_count DESC LIMIT 5" + ) + print("\nTop 5 agents by run count:") + for row in sample_runs: + print(f" - Agent {row['agentGraphId'][:8]}...: {row['run_count']} runs") + + sample_reviews = await db.query_raw( + "SELECT * FROM mv_review_stats ORDER BY avg_rating DESC NULLS LAST LIMIT 5" + ) + print("\nTop 5 store listings by rating:") + for row in sample_reviews: + avg_rating = row["avg_rating"] if row["avg_rating"] is not None else 0.0 + print( + f" - Listing {row['storeListingId'][:8]}...: {avg_rating:.2f} ⭐ ({row['review_count']} reviews)" + ) + + await db.disconnect() + + print("\n" + "=" * 60) + print("Test data update completed successfully!") + print("The materialized views should now reflect the updated data.") + print( + "\nTo manually refresh views, run: SELECT refresh_store_materialized_views();" + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/autogpt_platform/backend/test/util/test_decorator.py b/autogpt_platform/backend/test/util/test_decorator.py deleted file mode 100644 index de38b747d8ae..000000000000 --- a/autogpt_platform/backend/test/util/test_decorator.py +++ /dev/null @@ -1,26 +0,0 @@ -import time - -from backend.util.decorator import error_logged, time_measured - - -@time_measured -def example_function(a: int, b: int, c: int) -> int: - time.sleep(0.5) - return a + b + c - - -@error_logged -def example_function_with_error(a: int, b: int, c: int) -> int: - raise ValueError("This is a test error") - - -def test_timer_decorator(): - info, res = example_function(1, 2, 3) - assert info.cpu_time >= 0 - assert info.wall_time >= 0.4 - assert res == 6 - - -def test_error_decorator(): - res = example_function_with_error(1, 2, 3) - assert res is None diff --git a/autogpt_platform/backend/test/util/test_request.py b/autogpt_platform/backend/test/util/test_request.py deleted file mode 100644 index 703ef9eb5bb4..000000000000 --- a/autogpt_platform/backend/test/util/test_request.py +++ /dev/null @@ -1,79 +0,0 @@ -import pytest - -from backend.util.request import validate_url - - -def test_validate_url(): - # Rejected IP ranges - with pytest.raises(ValueError): - validate_url("localhost", []) - - with pytest.raises(ValueError): - validate_url("192.168.1.1", []) - - with pytest.raises(ValueError): - validate_url("127.0.0.1", []) - - with pytest.raises(ValueError): - validate_url("0.0.0.0", []) - - # Normal URLs - assert validate_url("google.com/a?b=c", []) == "http://google.com/a?b=c" - assert validate_url("github.com?key=!@!@", []) == "http://github.com?key=!@!@" - - # Scheme Enforcement - with pytest.raises(ValueError): - validate_url("ftp://example.com", []) - with pytest.raises(ValueError): - validate_url("file://example.com", []) - - # International domain that converts to punycode - should be allowed if public - assert validate_url("http://xn--exmple-cua.com", []) == "http://xn--exmple-cua.com" - # If the domain fails IDNA encoding or is invalid, it should raise an error - with pytest.raises(ValueError): - validate_url("http://exa◌mple.com", []) - - # IPv6 Addresses - with pytest.raises(ValueError): - validate_url("::1", []) # IPv6 loopback should be blocked - with pytest.raises(ValueError): - validate_url("http://[::1]", []) # IPv6 loopback in URL form - - # Suspicious Characters in Hostname - with pytest.raises(ValueError): - validate_url("http://example_underscore.com", []) - with pytest.raises(ValueError): - validate_url("http://exa mple.com", []) # Space in hostname - - # Malformed URLs - with pytest.raises(ValueError): - validate_url("http://", []) # No hostname - with pytest.raises(ValueError): - validate_url("://missing-scheme", []) # Missing proper scheme - - # Trusted Origins - trusted = ["internal-api.company.com", "10.0.0.5"] - assert ( - validate_url("internal-api.company.com", trusted) - == "http://internal-api.company.com" - ) - assert validate_url("10.0.0.5", ["10.0.0.5"]) == "http://10.0.0.5" - - # Special Characters in Path or Query - assert ( - validate_url("example.com/path%20with%20spaces", []) - == "http://example.com/path%20with%20spaces" - ) - - # Backslashes should be replaced with forward slashes - assert ( - validate_url("http://example.com\\backslash", []) - == "http://example.com/backslash" - ) - - # Check defaulting scheme behavior for valid domains - assert validate_url("example.com", []) == "http://example.com" - assert validate_url("https://secure.com", []) == "https://secure.com" - - # Non-ASCII Characters in Query/Fragment - assert validate_url("example.com?param=äöü", []) == "http://example.com?param=äöü" diff --git a/autogpt_platform/backend/test/util/test_retry.py b/autogpt_platform/backend/test/util/test_retry.py deleted file mode 100644 index d3192f4f9fac..000000000000 --- a/autogpt_platform/backend/test/util/test_retry.py +++ /dev/null @@ -1,49 +0,0 @@ -import asyncio - -import pytest - -from backend.util.retry import conn_retry - - -def test_conn_retry_sync_function(): - retry_count = 0 - - @conn_retry("Test", "Test function", max_retry=2, max_wait=0.1, min_wait=0.1) - def test_function(): - nonlocal retry_count - retry_count -= 1 - if retry_count > 0: - raise ValueError("Test error") - return "Success" - - retry_count = 2 - res = test_function() - assert res == "Success" - - retry_count = 100 - with pytest.raises(ValueError) as e: - test_function() - assert str(e.value) == "Test error" - - -@pytest.mark.asyncio -async def test_conn_retry_async_function(): - retry_count = 0 - - @conn_retry("Test", "Test function", max_retry=2, max_wait=0.1, min_wait=0.1) - async def test_function(): - nonlocal retry_count - await asyncio.sleep(1) - retry_count -= 1 - if retry_count > 0: - raise ValueError("Test error") - return "Success" - - retry_count = 2 - res = await test_function() - assert res == "Success" - - retry_count = 100 - with pytest.raises(ValueError) as e: - await test_function() - assert str(e.value) == "Test error" diff --git a/autogpt_platform/backend/test/util/test_service.py b/autogpt_platform/backend/test/util/test_service.py deleted file mode 100644 index a20810dbb1e5..000000000000 --- a/autogpt_platform/backend/test/util/test_service.py +++ /dev/null @@ -1,38 +0,0 @@ -import pytest - -from backend.util.service import AppService, expose, get_service_client - -TEST_SERVICE_PORT = 8765 - - -class ServiceTest(AppService): - def __init__(self): - super().__init__() - - @classmethod - def get_port(cls) -> int: - return TEST_SERVICE_PORT - - @expose - def add(self, a: int, b: int) -> int: - return a + b - - @expose - def subtract(self, a: int, b: int) -> int: - return a - b - - @expose - def fun_with_async(self, a: int, b: int) -> int: - async def add_async(a: int, b: int) -> int: - return a + b - - return self.run_and_wait(add_async(a, b)) - - -@pytest.mark.asyncio(scope="session") -async def test_service_creation(server): - with ServiceTest(): - client = get_service_client(ServiceTest) - assert client.add(5, 3) == 8 - assert client.subtract(10, 4) == 6 - assert client.fun_with_async(5, 3) == 8 diff --git a/autogpt_platform/backend/test/util/test_type.py b/autogpt_platform/backend/test/util/test_type.py deleted file mode 100644 index f9a14d10a00c..000000000000 --- a/autogpt_platform/backend/test/util/test_type.py +++ /dev/null @@ -1,32 +0,0 @@ -from backend.util.type import convert - - -def test_type_conversion(): - assert convert(5.5, int) == 5 - assert convert("5.5", int) == 5 - assert convert([1, 2, 3], int) == 3 - - assert convert("5.5", float) == 5.5 - assert convert(5, float) == 5.0 - - assert convert("True", bool) is True - assert convert("False", bool) is False - - assert convert(5, str) == "5" - assert convert({"a": 1, "b": 2}, str) == '{"a": 1, "b": 2}' - assert convert([1, 2, 3], str) == "[1, 2, 3]" - - assert convert("5", list) == ["5"] - assert convert((1, 2, 3), list) == [1, 2, 3] - assert convert({1, 2, 3}, list) == [1, 2, 3] - - assert convert("5", dict) == {"value": 5} - assert convert('{"a": 1, "b": 2}', dict) == {"a": 1, "b": 2} - assert convert([1, 2, 3], dict) == {0: 1, 1: 2, 2: 3} - assert convert((1, 2, 3), dict) == {0: 1, 1: 2, 2: 3} - - from typing import List - - assert convert("5", List[int]) == [5] - assert convert("[5,4,2]", List[int]) == [5, 4, 2] - assert convert([5, 4, 2], List[str]) == ["5", "4", "2"] diff --git a/autogpt_platform/db/docker/.gitignore b/autogpt_platform/db/docker/.gitignore new file mode 100644 index 000000000000..a8c501f97b5f --- /dev/null +++ b/autogpt_platform/db/docker/.gitignore @@ -0,0 +1,4 @@ +volumes/db/data +volumes/storage +test.http +docker-compose.override.yml diff --git a/autogpt_platform/db/docker/README.md b/autogpt_platform/db/docker/README.md new file mode 100644 index 000000000000..9ab215b9027f --- /dev/null +++ b/autogpt_platform/db/docker/README.md @@ -0,0 +1,3 @@ +# Supabase Docker + +This is a minimal Docker Compose setup for self-hosting Supabase. Follow the steps [here](https://supabase.com/docs/guides/hosting/docker) to get started. diff --git a/autogpt_platform/db/docker/dev/data.sql b/autogpt_platform/db/docker/dev/data.sql new file mode 100644 index 000000000000..2328004184a7 --- /dev/null +++ b/autogpt_platform/db/docker/dev/data.sql @@ -0,0 +1,48 @@ +create table profiles ( + id uuid references auth.users not null, + updated_at timestamp with time zone, + username text unique, + avatar_url text, + website text, + + primary key (id), + unique(username), + constraint username_length check (char_length(username) >= 3) +); + +alter table profiles enable row level security; + +create policy "Public profiles are viewable by the owner." + on profiles for select + using ( auth.uid() = id ); + +create policy "Users can insert their own profile." + on profiles for insert + with check ( auth.uid() = id ); + +create policy "Users can update own profile." + on profiles for update + using ( auth.uid() = id ); + +-- Set up Realtime +begin; + drop publication if exists supabase_realtime; + create publication supabase_realtime; +commit; +alter publication supabase_realtime add table profiles; + +-- Set up Storage +insert into storage.buckets (id, name) +values ('avatars', 'avatars'); + +create policy "Avatar images are publicly accessible." + on storage.objects for select + using ( bucket_id = 'avatars' ); + +create policy "Anyone can upload an avatar." + on storage.objects for insert + with check ( bucket_id = 'avatars' ); + +create policy "Anyone can update an avatar." + on storage.objects for update + with check ( bucket_id = 'avatars' ); diff --git a/autogpt_platform/db/docker/dev/docker-compose.dev.yml b/autogpt_platform/db/docker/dev/docker-compose.dev.yml new file mode 100644 index 000000000000..ca19a0ad7814 --- /dev/null +++ b/autogpt_platform/db/docker/dev/docker-compose.dev.yml @@ -0,0 +1,34 @@ +version: "3.8" + +services: + studio: + build: + context: .. + dockerfile: studio/Dockerfile + target: dev + ports: + - 8082:8082 + mail: + container_name: supabase-mail + image: inbucket/inbucket:3.0.3 + ports: + - '2500:2500' # SMTP + - '9000:9000' # web interface + - '1100:1100' # POP3 + auth: + environment: + - GOTRUE_SMTP_USER= + - GOTRUE_SMTP_PASS= + meta: + ports: + - 5555:8080 + db: + restart: 'no' + volumes: + # Always use a fresh database when developing + - /var/lib/postgresql/data + # Seed data should be inserted last (alphabetical order) + - ./dev/data.sql:/docker-entrypoint-initdb.d/seed.sql + storage: + volumes: + - /var/lib/storage diff --git a/autogpt_platform/db/docker/docker-compose.s3.yml b/autogpt_platform/db/docker/docker-compose.s3.yml new file mode 100644 index 000000000000..043691a607b2 --- /dev/null +++ b/autogpt_platform/db/docker/docker-compose.s3.yml @@ -0,0 +1,94 @@ +services: + + minio: + image: minio/minio + ports: + - '9000:9000' + - '9001:9001' + environment: + MINIO_ROOT_USER: supa-storage + MINIO_ROOT_PASSWORD: secret1234 + command: server --console-address ":9001" /data + healthcheck: + test: [ "CMD", "curl", "-f", "http://minio:9000/minio/health/live" ] + interval: 2s + timeout: 10s + retries: 5 + volumes: + - ./volumes/storage:/data:z + + minio-createbucket: + image: minio/mc + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + /usr/bin/mc alias set supa-minio http://minio:9000 supa-storage secret1234; + /usr/bin/mc mb supa-minio/stub; + exit 0; + " + + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.11.13 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + minio: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + restart: unless-stopped + environment: + ANON_KEY: ${ANON_KEY} + SERVICE_KEY: ${SERVICE_ROLE_KEY} + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: ${JWT_SECRET} + DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB} + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: s3 + GLOBAL_S3_BUCKET: stub + GLOBAL_S3_ENDPOINT: http://minio:9000 + GLOBAL_S3_PROTOCOL: http + GLOBAL_S3_FORCE_PATH_STYLE: true + AWS_ACCESS_KEY_ID: supa-storage + AWS_SECRET_ACCESS_KEY: secret1234 + AWS_DEFAULT_REGION: stub + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + volumes: + - ./volumes/storage:/var/lib/storage:z + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.8.0 + healthcheck: + test: [ "CMD", "imgproxy", "health" ] + timeout: 5s + interval: 5s + retries: 3 + environment: + IMGPROXY_BIND: ":5001" + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: ${IMGPROXY_ENABLE_WEBP_DETECTION} diff --git a/autogpt_platform/db/docker/docker-compose.yml b/autogpt_platform/db/docker/docker-compose.yml new file mode 100644 index 000000000000..9774cccb331e --- /dev/null +++ b/autogpt_platform/db/docker/docker-compose.yml @@ -0,0 +1,638 @@ +# Usage +# Start: docker compose up +# With helpers: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml up +# Stop: docker compose down +# Destroy: docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans +# Reset everything: ./reset.sh + +# Environment Variable Loading Order (first → last, later overrides earlier): +# 1. ../../.env.default - Default values for all Supabase settings +# 2. ../../.env - User's custom configuration (if exists) +# 3. ./.env - Local overrides specific to db/docker (if exists) +# 4. environment key - Service-specific overrides defined below +# 5. Shell environment - Variables exported before running docker compose + +name: supabase + +# Common env_file configuration for all Supabase services +x-supabase-env-files: &supabase-env-files + env_file: + - ../../.env.default # Base defaults from platform root + - path: ../../.env # User overrides from platform root (optional) + required: false + - path: ./.env # Local overrides for db/docker (optional) + required: false + +# Common Supabase environment - hardcoded defaults to avoid variable substitution +x-supabase-env: &supabase-env + # Core PostgreSQL settings + POSTGRES_PASSWORD: your-super-secret-and-long-postgres-password + POSTGRES_HOST: db + POSTGRES_PORT: "5432" + POSTGRES_DB: postgres + + # Authentication & Security + JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE + SERVICE_ROLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q + DASHBOARD_USERNAME: supabase + DASHBOARD_PASSWORD: this_password_is_insecure_and_should_be_updated + SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + VAULT_ENC_KEY: your-encryption-key-32-chars-min + + # URLs and Endpoints + SITE_URL: http://localhost:3000 + API_EXTERNAL_URL: http://localhost:8000 + SUPABASE_PUBLIC_URL: http://localhost:8000 + ADDITIONAL_REDIRECT_URLS: "" + + # Feature Flags + DISABLE_SIGNUP: "false" + ENABLE_EMAIL_SIGNUP: "true" + ENABLE_EMAIL_AUTOCONFIRM: "false" + ENABLE_ANONYMOUS_USERS: "false" + ENABLE_PHONE_SIGNUP: "true" + ENABLE_PHONE_AUTOCONFIRM: "true" + FUNCTIONS_VERIFY_JWT: "false" + IMGPROXY_ENABLE_WEBP_DETECTION: "true" + + # Email/SMTP Configuration + SMTP_ADMIN_EMAIL: admin@example.com + SMTP_HOST: supabase-mail + SMTP_PORT: "2500" + SMTP_USER: fake_mail_user + SMTP_PASS: fake_mail_password + SMTP_SENDER_NAME: fake_sender + + # Mailer URLs + MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify + MAILER_URLPATHS_INVITE: /auth/v1/verify + MAILER_URLPATHS_RECOVERY: /auth/v1/verify + MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + + # JWT Settings + JWT_EXPIRY: "3600" + + # Database Schemas + PGRST_DB_SCHEMAS: public,storage,graphql_public + + # Studio Settings + STUDIO_DEFAULT_ORGANIZATION: Default Organization + STUDIO_DEFAULT_PROJECT: Default Project + + # Logging + LOGFLARE_API_KEY: your-super-secret-and-long-logflare-key + + # Pooler Settings + POOLER_DEFAULT_POOL_SIZE: "20" + POOLER_MAX_CLIENT_CONN: "100" + POOLER_TENANT_ID: your-tenant-id + POOLER_PROXY_PORT_TRANSACTION: "6543" + + # Kong Ports + KONG_HTTP_PORT: "8000" + KONG_HTTPS_PORT: "8443" + + # Docker + DOCKER_SOCKET_LOCATION: /var/run/docker.sock + + # Google Cloud (if needed) + GOOGLE_PROJECT_ID: GOOGLE_PROJECT_ID + GOOGLE_PROJECT_NUMBER: GOOGLE_PROJECT_NUMBER + +services: + + studio: + container_name: supabase-studio + image: supabase/studio:20250224-d10db0f + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "node", + "-e", + "fetch('http://studio:3000/api/platform/profile').then((r) => {if (r.status !== 200) throw new Error(r.status)})" + ] + timeout: 10s + interval: 5s + retries: 3 + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + STUDIO_PG_META_URL: http://meta:8080 + POSTGRES_PASSWORD: your-super-secret-and-long-postgres-password + + DEFAULT_ORGANIZATION_NAME: Default Organization + DEFAULT_PROJECT_NAME: Default Project + OPENAI_API_KEY: "" + + SUPABASE_URL: http://kong:8000 + SUPABASE_PUBLIC_URL: http://localhost:8000 + SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE + SUPABASE_SERVICE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q + AUTH_JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + + LOGFLARE_API_KEY: your-super-secret-and-long-logflare-key + LOGFLARE_URL: http://analytics:4000 + NEXT_PUBLIC_ENABLE_LOGS: true + # Comment to use Big Query backend for analytics + NEXT_ANALYTICS_BACKEND_PROVIDER: postgres + # Uncomment to use Big Query backend for analytics + # NEXT_ANALYTICS_BACKEND_PROVIDER: bigquery + + kong: + container_name: supabase-kong + image: kong:2.8.1 + restart: unless-stopped + ports: + - 8000:8000/tcp + - 8443:8443/tcp + volumes: + # https://github.com/supabase/supabase/issues/12661 + - ./volumes/api/kong.yml:/home/kong/temp.yml:ro + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml + # https://github.com/supabase/cli/issues/14 + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth + KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k + KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k + SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE + SUPABASE_SERVICE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q + DASHBOARD_USERNAME: supabase + DASHBOARD_PASSWORD: this_password_is_insecure_and_should_be_updated + # https://unix.stackexchange.com/a/294837 + entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start' + + auth: + container_name: supabase-auth + image: supabase/gotrue:v2.170.0 + restart: unless-stopped + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://localhost:9999/health" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: http://localhost:8000 + + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:your-super-secret-and-long-postgres-password@db:5432/postgres + + GOTRUE_SITE_URL: http://localhost:3000 + GOTRUE_URI_ALLOW_LIST: "" + GOTRUE_DISABLE_SIGNUP: false + + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: 3600 + GOTRUE_JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + + GOTRUE_EXTERNAL_EMAIL_ENABLED: true + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: false + GOTRUE_MAILER_AUTOCONFIRM: false + + # Uncomment to bypass nonce check in ID Token flow. Commonly set to true when using Google Sign In on mobile. + # GOTRUE_EXTERNAL_SKIP_NONCE_CHECK: true + + # GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: true + # GOTRUE_SMTP_MAX_FREQUENCY: 1s + GOTRUE_SMTP_ADMIN_EMAIL: admin@example.com + GOTRUE_SMTP_HOST: supabase-mail + GOTRUE_SMTP_PORT: 2500 + GOTRUE_SMTP_USER: fake_mail_user + GOTRUE_SMTP_PASS: fake_mail_password + GOTRUE_SMTP_SENDER_NAME: fake_sender + GOTRUE_MAILER_URLPATHS_INVITE: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/v1/verify + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify + + GOTRUE_EXTERNAL_PHONE_ENABLED: true + GOTRUE_SMS_AUTOCONFIRM: true + # Uncomment to enable custom access token hook. Please see: https://supabase.com/docs/guides/auth/auth-hooks for full list of hooks and additional details about custom_access_token_hook + + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "" + + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_MFA_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/mfa_verification_attempt" + + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_ENABLED: "true" + # GOTRUE_HOOK_PASSWORD_VERIFICATION_ATTEMPT_URI: "pg-functions://postgres/public/password_verification_attempt" + + # GOTRUE_HOOK_SEND_SMS_ENABLED: "false" + # GOTRUE_HOOK_SEND_SMS_URI: "pg-functions://postgres/public/custom_access_token_hook" + # GOTRUE_HOOK_SEND_SMS_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + # GOTRUE_HOOK_SEND_EMAIL_ENABLED: "false" + # GOTRUE_HOOK_SEND_EMAIL_URI: "http://host.docker.internal:54321/functions/v1/email_sender" + # GOTRUE_HOOK_SEND_EMAIL_SECRETS: "v1,whsec_VGhpcyBpcyBhbiBleGFtcGxlIG9mIGEgc2hvcnRlciBCYXNlNjQgc3RyaW5n" + + rest: + container_name: supabase-rest + image: postgrest/postgrest:v12.2.8 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + PGRST_DB_URI: postgres://authenticator:your-super-secret-and-long-postgres-password@db:5432/postgres + PGRST_DB_SCHEMAS: public,storage,graphql_public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + PGRST_DB_USE_LEGACY_GUCS: "false" + PGRST_APP_SETTINGS_JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + PGRST_APP_SETTINGS_JWT_EXP: 3600 + command: + [ + "postgrest" + ] + + realtime: + # This container name looks inconsistent but is correct because realtime constructs tenant id by parsing the subdomain + container_name: realtime-dev.supabase-realtime + image: supabase/realtime:v2.34.40 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "-H", + "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE", + "http://localhost:4000/api/tenants/realtime-dev/health" + ] + timeout: 5s + interval: 5s + retries: 3 + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + PORT: 4000 + DB_HOST: db + DB_PORT: 5432 + DB_USER: supabase_admin + DB_PASSWORD: your-super-secret-and-long-postgres-password + DB_NAME: postgres + DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime' + DB_ENC_KEY: supabaserealtime + API_JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + ERL_AFLAGS: -proto_dist inet_tcp + DNS_NODES: "''" + RLIMIT_NOFILE: "10000" + APP_NAME: realtime + SEED_SELF_HOST: true + RUN_JANITOR: true + + # To use S3 backed storage: docker compose -f docker-compose.yml -f docker-compose.s3.yml up + storage: + container_name: supabase-storage + image: supabase/storage-api:v1.19.3 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage:z + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://storage:5000/status" + ] + timeout: 5s + interval: 5s + retries: 3 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + rest: + condition: service_started + imgproxy: + condition: service_started + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE + SERVICE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q + POSTGREST_URL: http://rest:3000 + PGRST_JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + DATABASE_URL: postgres://supabase_storage_admin:your-super-secret-and-long-postgres-password@db:5432/postgres + FILE_SIZE_LIMIT: 52428800 + STORAGE_BACKEND: file + FILE_STORAGE_BACKEND_PATH: /var/lib/storage + TENANT_ID: stub + # TODO: https://github.com/supabase/storage-api/issues/55 + REGION: stub + GLOBAL_S3_BUCKET: stub + ENABLE_IMAGE_TRANSFORMATION: "true" + IMGPROXY_URL: http://imgproxy:5001 + + imgproxy: + container_name: supabase-imgproxy + image: darthsim/imgproxy:v3.8.0 + restart: unless-stopped + volumes: + - ./volumes/storage:/var/lib/storage:z + healthcheck: + test: + [ + "CMD", + "imgproxy", + "health" + ] + timeout: 5s + interval: 5s + retries: 3 + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + IMGPROXY_BIND: ":5001" + IMGPROXY_LOCAL_FILESYSTEM_ROOT: / + IMGPROXY_USE_ETAG: "true" + IMGPROXY_ENABLE_WEBP_DETECTION: true + + meta: + container_name: supabase-meta + image: supabase/postgres-meta:v0.86.1 + restart: unless-stopped + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + PG_META_PORT: 8080 + PG_META_DB_HOST: db + PG_META_DB_PORT: 5432 + PG_META_DB_NAME: postgres + PG_META_DB_USER: supabase_admin + PG_META_DB_PASSWORD: your-super-secret-and-long-postgres-password + + functions: + container_name: supabase-edge-functions + image: supabase/edge-runtime:v1.67.2 + restart: unless-stopped + volumes: + - ./volumes/functions:/home/deno/functions:Z + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + SUPABASE_URL: http://kong:8000 + SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE + SUPABASE_SERVICE_ROLE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q + SUPABASE_DB_URL: postgresql://postgres:your-super-secret-and-long-postgres-password@db:5432/postgres + # TODO: Allow configuring VERIFY_JWT per function. This PR might help: https://github.com/supabase/cli/pull/786 + VERIFY_JWT: "false" + command: + [ + "start", + "--main-service", + "/home/deno/functions/main" + ] + + analytics: + container_name: supabase-analytics + image: supabase/logflare:1.12.5 + restart: unless-stopped + ports: + - 4000:4000 + # Uncomment to use Big Query backend for analytics + # volumes: + # - type: bind + # source: ${PWD}/gcloud.json + # target: /opt/app/rel/logflare/bin/gcloud.json + # read_only: true + healthcheck: + test: + [ + "CMD", + "curl", + "http://localhost:4000/health" + ] + timeout: 5s + interval: 5s + retries: 10 + depends_on: + db: + # Disable this if you are using an external Postgres database + condition: service_healthy + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + LOGFLARE_NODE_HOST: 127.0.0.1 + DB_USERNAME: supabase_admin + DB_DATABASE: _supabase + DB_HOSTNAME: db + DB_PORT: 5432 + DB_PASSWORD: your-super-secret-and-long-postgres-password + DB_SCHEMA: _analytics + LOGFLARE_API_KEY: your-super-secret-and-long-logflare-key + LOGFLARE_SINGLE_TENANT: true + LOGFLARE_SUPABASE_MODE: true + LOGFLARE_MIN_CLUSTER_SIZE: 1 + + # Comment variables to use Big Query backend for analytics + POSTGRES_BACKEND_URL: postgresql://supabase_admin:your-super-secret-and-long-postgres-password@db:5432/_supabase + POSTGRES_BACKEND_SCHEMA: _analytics + LOGFLARE_FEATURE_FLAG_OVERRIDE: multibackend=true + # Uncomment to use Big Query backend for analytics + # GOOGLE_PROJECT_ID: GOOGLE_PROJECT_ID + # GOOGLE_PROJECT_NUMBER: GOOGLE_PROJECT_NUMBER + + # Comment out everything below this point if you are using an external Postgres database + db: + container_name: supabase-db + image: supabase/postgres:15.8.1.049 + restart: unless-stopped + volumes: + - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z + # Must be superuser to create event trigger + - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z + # Must be superuser to alter reserved role + - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z + # Initialize the database settings with JWT_SECRET and JWT_EXP + - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z + # PGDATA directory is persisted between restarts + - ./volumes/db/data:/var/lib/postgresql/data:Z + # Changes required for internal supabase data such as _analytics + - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z + # Changes required for Analytics support + - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z + # Changes required for Pooler support + - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z + # Use named volume to persist pgsodium decryption key between restarts + - supabase-config:/etc/postgresql-custom + healthcheck: + test: + [ + "CMD", + "pg_isready", + "-U", + "postgres", + "-h", + "localhost" + ] + interval: 5s + timeout: 5s + retries: 10 + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + POSTGRES_HOST: /var/run/postgresql + PGPORT: 5432 + POSTGRES_PORT: 5432 + PGPASSWORD: your-super-secret-and-long-postgres-password + POSTGRES_PASSWORD: your-super-secret-and-long-postgres-password + PGDATABASE: postgres + POSTGRES_DB: postgres + JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + JWT_EXP: 3600 + command: + [ + "postgres", + "-c", + "config_file=/etc/postgresql/postgresql.conf", + "-c", + "log_min_messages=fatal" # prevents Realtime polling queries from appearing in logs + ] + + vector: + container_name: supabase-vector + image: timberio/vector:0.28.1-alpine + restart: unless-stopped + volumes: + - ./volumes/logs/vector.yml:/etc/vector/vector.yml:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + healthcheck: + test: + [ + "CMD", + "wget", + "--no-verbose", + "--tries=1", + "--spider", + "http://vector:9001/health" + ] + timeout: 5s + interval: 5s + retries: 3 + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + LOGFLARE_API_KEY: your-super-secret-and-long-logflare-key + command: + [ + "--config", + "/etc/vector/vector.yml" + ] + + # Update the DATABASE_URL if you are using an external Postgres database + supavisor: + container_name: supabase-pooler + image: supabase/supavisor:2.4.12 + restart: unless-stopped + ports: + - 5432:5432 + - 6543:6543 + volumes: + - ./volumes/pooler/pooler.exs:/etc/pooler/pooler.exs:ro + healthcheck: + test: + [ + "CMD", + "curl", + "-sSfL", + "--head", + "-o", + "/dev/null", + "http://127.0.0.1:4000/api/health" + ] + interval: 10s + timeout: 5s + retries: 5 + depends_on: + db: + condition: service_healthy + analytics: + condition: service_healthy + <<: *supabase-env-files + environment: + <<: *supabase-env + # Keep any existing environment variables specific to that service + PORT: 4000 + POSTGRES_PORT: 5432 + POSTGRES_DB: postgres + POSTGRES_PASSWORD: your-super-secret-and-long-postgres-password + DATABASE_URL: ecto://supabase_admin:your-super-secret-and-long-postgres-password@db:5432/_supabase + CLUSTER_POSTGRES: true + SECRET_KEY_BASE: UpNVntn3cDxHJpq99YMc1T1AQgQpc8kfYTuRgBiYa15BLrx8etQoXz3gZv1/u2oq + VAULT_ENC_KEY: your-encryption-key-32-chars-min + API_JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + METRICS_JWT_SECRET: your-super-secret-jwt-token-with-at-least-32-characters-long + REGION: local + ERL_AFLAGS: -proto_dist inet_tcp + POOLER_TENANT_ID: your-tenant-id + POOLER_DEFAULT_POOL_SIZE: 20 + POOLER_MAX_CLIENT_CONN: 100 + POOLER_POOL_MODE: transaction + command: + [ + "/bin/sh", + "-c", + "/app/bin/migrate && /app/bin/supavisor eval \"$$(cat /etc/pooler/pooler.exs)\" && /app/bin/server" + ] + +volumes: + supabase-config: diff --git a/autogpt_platform/db/docker/reset.sh b/autogpt_platform/db/docker/reset.sh new file mode 100755 index 000000000000..2efe8983206e --- /dev/null +++ b/autogpt_platform/db/docker/reset.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +echo "WARNING: This will remove all containers and container data, and will reset the .env file. This action cannot be undone!" +read -p "Are you sure you want to proceed? (y/N) " -n 1 -r +echo # Move to a new line +if [[ ! $REPLY =~ ^[Yy]$ ]] +then + echo "Operation cancelled." + exit 1 +fi + +echo "Stopping and removing all containers..." +docker compose -f docker-compose.yml -f ./dev/docker-compose.dev.yml down -v --remove-orphans + +echo "Cleaning up bind-mounted directories..." +BIND_MOUNTS=( + "./volumes/db/data" +) + +for DIR in "${BIND_MOUNTS[@]}"; do + if [ -d "$DIR" ]; then + echo "Deleting $DIR..." + rm -rf "$DIR" + else + echo "Directory $DIR does not exist. Skipping bind mount deletion step..." + fi +done + +echo "Resetting .env file..." +if [ -f ".env" ]; then + echo "Removing existing .env file..." + rm -f .env +else + echo "No .env file found. Skipping .env removal step..." +fi + +if [ -f ".env.default" ]; then + echo "Copying .env.default to .env..." + cp .env.default .env +else + echo ".env.default file not found. Skipping .env reset step..." +fi + +echo "Cleanup complete!" \ No newline at end of file diff --git a/autogpt_platform/db/docker/volumes/api/kong.yml b/autogpt_platform/db/docker/volumes/api/kong.yml new file mode 100644 index 000000000000..7abf42534c91 --- /dev/null +++ b/autogpt_platform/db/docker/volumes/api/kong.yml @@ -0,0 +1,241 @@ +_format_version: '2.1' +_transform: true + +### +### Consumers / Users +### +consumers: + - username: DASHBOARD + - username: anon + keyauth_credentials: + - key: $SUPABASE_ANON_KEY + - username: service_role + keyauth_credentials: + - key: $SUPABASE_SERVICE_KEY + +### +### Access Control List +### +acls: + - consumer: anon + group: anon + - consumer: service_role + group: admin + +### +### Dashboard credentials +### +basicauth_credentials: + - consumer: DASHBOARD + username: $DASHBOARD_USERNAME + password: $DASHBOARD_PASSWORD + +### +### API Routes +### +services: + ## Open Auth routes + - name: auth-v1-open + url: http://auth:9999/verify + routes: + - name: auth-v1-open + strip_path: true + paths: + - /auth/v1/verify + plugins: + - name: cors + - name: auth-v1-open-callback + url: http://auth:9999/callback + routes: + - name: auth-v1-open-callback + strip_path: true + paths: + - /auth/v1/callback + plugins: + - name: cors + - name: auth-v1-open-authorize + url: http://auth:9999/authorize + routes: + - name: auth-v1-open-authorize + strip_path: true + paths: + - /auth/v1/authorize + plugins: + - name: cors + + ## Secure Auth routes + - name: auth-v1 + _comment: 'GoTrue: /auth/v1/* -> http://auth:9999/*' + url: http://auth:9999/ + routes: + - name: auth-v1-all + strip_path: true + paths: + - /auth/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure REST routes + - name: rest-v1 + _comment: 'PostgREST: /rest/v1/* -> http://rest:3000/*' + url: http://rest:3000/ + routes: + - name: rest-v1-all + strip_path: true + paths: + - /rest/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure GraphQL routes + - name: graphql-v1 + _comment: 'PostgREST: /graphql/v1/* -> http://rest:3000/rpc/graphql' + url: http://rest:3000/rpc/graphql + routes: + - name: graphql-v1-all + strip_path: true + paths: + - /graphql/v1 + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: true + - name: request-transformer + config: + add: + headers: + - Content-Profile:graphql_public + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + + ## Secure Realtime routes + - name: realtime-v1-ws + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/socket + protocol: ws + routes: + - name: realtime-v1-ws + strip_path: true + paths: + - /realtime/v1/ + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + - name: realtime-v1-rest + _comment: 'Realtime: /realtime/v1/* -> ws://realtime:4000/socket/*' + url: http://realtime-dev.supabase-realtime:4000/api + protocol: http + routes: + - name: realtime-v1-rest + strip_path: true + paths: + - /realtime/v1/api + plugins: + - name: cors + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + - anon + ## Storage routes: the storage server manages its own auth + - name: storage-v1 + _comment: 'Storage: /storage/v1/* -> http://storage:5000/*' + url: http://storage:5000/ + routes: + - name: storage-v1-all + strip_path: true + paths: + - /storage/v1/ + plugins: + - name: cors + + ## Edge Functions routes + - name: functions-v1 + _comment: 'Edge Functions: /functions/v1/* -> http://functions:9000/*' + url: http://functions:9000/ + routes: + - name: functions-v1-all + strip_path: true + paths: + - /functions/v1/ + plugins: + - name: cors + + ## Analytics routes + - name: analytics-v1 + _comment: 'Analytics: /analytics/v1/* -> http://logflare:4000/*' + url: http://analytics:4000/ + routes: + - name: analytics-v1-all + strip_path: true + paths: + - /analytics/v1/ + + ## Secure Database routes + - name: meta + _comment: 'pg-meta: /pg/* -> http://pg-meta:8080/*' + url: http://meta:8080/ + routes: + - name: meta-all + strip_path: true + paths: + - /pg/ + plugins: + - name: key-auth + config: + hide_credentials: false + - name: acl + config: + hide_groups_header: true + allow: + - admin + + ## Protected Dashboard - catch all remaining routes + - name: dashboard + _comment: 'Studio: /* -> http://studio:3000/*' + url: http://studio:3000/ + routes: + - name: dashboard-all + strip_path: true + paths: + - / + plugins: + - name: cors + - name: basic-auth + config: + hide_credentials: true diff --git a/autogpt_platform/db/docker/volumes/db/_supabase.sql b/autogpt_platform/db/docker/volumes/db/_supabase.sql new file mode 100644 index 000000000000..6236ae1bccc2 --- /dev/null +++ b/autogpt_platform/db/docker/volumes/db/_supabase.sql @@ -0,0 +1,3 @@ +\set pguser `echo "$POSTGRES_USER"` + +CREATE DATABASE _supabase WITH OWNER :pguser; diff --git a/autogpt_platform/db/docker/volumes/db/init/data.sql b/autogpt_platform/db/docker/volumes/db/init/data.sql new file mode 100755 index 000000000000..e69de29bb2d1 diff --git a/autogpt_platform/db/docker/volumes/db/jwt.sql b/autogpt_platform/db/docker/volumes/db/jwt.sql new file mode 100644 index 000000000000..cfd3b16028fa --- /dev/null +++ b/autogpt_platform/db/docker/volumes/db/jwt.sql @@ -0,0 +1,5 @@ +\set jwt_secret `echo "$JWT_SECRET"` +\set jwt_exp `echo "$JWT_EXP"` + +ALTER DATABASE postgres SET "app.settings.jwt_secret" TO :'jwt_secret'; +ALTER DATABASE postgres SET "app.settings.jwt_exp" TO :'jwt_exp'; diff --git a/autogpt_platform/db/docker/volumes/db/logs.sql b/autogpt_platform/db/docker/volumes/db/logs.sql new file mode 100644 index 000000000000..255c0f407b8d --- /dev/null +++ b/autogpt_platform/db/docker/volumes/db/logs.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _analytics; +alter schema _analytics owner to :pguser; +\c postgres diff --git a/autogpt_platform/db/docker/volumes/db/pooler.sql b/autogpt_platform/db/docker/volumes/db/pooler.sql new file mode 100644 index 000000000000..162c5b96aae5 --- /dev/null +++ b/autogpt_platform/db/docker/volumes/db/pooler.sql @@ -0,0 +1,6 @@ +\set pguser `echo "$POSTGRES_USER"` + +\c _supabase +create schema if not exists _supavisor; +alter schema _supavisor owner to :pguser; +\c postgres diff --git a/autogpt_platform/db/docker/volumes/db/realtime.sql b/autogpt_platform/db/docker/volumes/db/realtime.sql new file mode 100644 index 000000000000..4d4b9ffb912b --- /dev/null +++ b/autogpt_platform/db/docker/volumes/db/realtime.sql @@ -0,0 +1,4 @@ +\set pguser `echo "$POSTGRES_USER"` + +create schema if not exists _realtime; +alter schema _realtime owner to :pguser; diff --git a/autogpt_platform/db/docker/volumes/db/roles.sql b/autogpt_platform/db/docker/volumes/db/roles.sql new file mode 100644 index 000000000000..8f7161a6db66 --- /dev/null +++ b/autogpt_platform/db/docker/volumes/db/roles.sql @@ -0,0 +1,8 @@ +-- NOTE: change to your own passwords for production environments +\set pgpass `echo "$POSTGRES_PASSWORD"` + +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER pgbouncer WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_functions_admin WITH PASSWORD :'pgpass'; +ALTER USER supabase_storage_admin WITH PASSWORD :'pgpass'; diff --git a/autogpt_platform/db/docker/volumes/db/webhooks.sql b/autogpt_platform/db/docker/volumes/db/webhooks.sql new file mode 100644 index 000000000000..5837b86188ef --- /dev/null +++ b/autogpt_platform/db/docker/volumes/db/webhooks.sql @@ -0,0 +1,208 @@ +BEGIN; + -- Create pg_net extension + CREATE EXTENSION IF NOT EXISTS pg_net SCHEMA extensions; + -- Create supabase_functions schema + CREATE SCHEMA supabase_functions AUTHORIZATION supabase_admin; + GRANT USAGE ON SCHEMA supabase_functions TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON TABLES TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON FUNCTIONS TO postgres, anon, authenticated, service_role; + ALTER DEFAULT PRIVILEGES IN SCHEMA supabase_functions GRANT ALL ON SEQUENCES TO postgres, anon, authenticated, service_role; + -- supabase_functions.migrations definition + CREATE TABLE supabase_functions.migrations ( + version text PRIMARY KEY, + inserted_at timestamptz NOT NULL DEFAULT NOW() + ); + -- Initial supabase_functions migration + INSERT INTO supabase_functions.migrations (version) VALUES ('initial'); + -- supabase_functions.hooks definition + CREATE TABLE supabase_functions.hooks ( + id bigserial PRIMARY KEY, + hook_table_id integer NOT NULL, + hook_name text NOT NULL, + created_at timestamptz NOT NULL DEFAULT NOW(), + request_id bigint + ); + CREATE INDEX supabase_functions_hooks_request_id_idx ON supabase_functions.hooks USING btree (request_id); + CREATE INDEX supabase_functions_hooks_h_table_id_h_name_idx ON supabase_functions.hooks USING btree (hook_table_id, hook_name); + COMMENT ON TABLE supabase_functions.hooks IS 'Supabase Functions Hooks: Audit trail for triggered hooks.'; + CREATE FUNCTION supabase_functions.http_request() + RETURNS trigger + LANGUAGE plpgsql + AS $function$ + DECLARE + request_id bigint; + payload jsonb; + url text := TG_ARGV[0]::text; + method text := TG_ARGV[1]::text; + headers jsonb DEFAULT '{}'::jsonb; + params jsonb DEFAULT '{}'::jsonb; + timeout_ms integer DEFAULT 1000; + BEGIN + IF url IS NULL OR url = 'null' THEN + RAISE EXCEPTION 'url argument is missing'; + END IF; + + IF method IS NULL OR method = 'null' THEN + RAISE EXCEPTION 'method argument is missing'; + END IF; + + IF TG_ARGV[2] IS NULL OR TG_ARGV[2] = 'null' THEN + headers = '{"Content-Type": "application/json"}'::jsonb; + ELSE + headers = TG_ARGV[2]::jsonb; + END IF; + + IF TG_ARGV[3] IS NULL OR TG_ARGV[3] = 'null' THEN + params = '{}'::jsonb; + ELSE + params = TG_ARGV[3]::jsonb; + END IF; + + IF TG_ARGV[4] IS NULL OR TG_ARGV[4] = 'null' THEN + timeout_ms = 1000; + ELSE + timeout_ms = TG_ARGV[4]::integer; + END IF; + + CASE + WHEN method = 'GET' THEN + SELECT http_get INTO request_id FROM net.http_get( + url, + params, + headers, + timeout_ms + ); + WHEN method = 'POST' THEN + payload = jsonb_build_object( + 'old_record', OLD, + 'record', NEW, + 'type', TG_OP, + 'table', TG_TABLE_NAME, + 'schema', TG_TABLE_SCHEMA + ); + + SELECT http_post INTO request_id FROM net.http_post( + url, + payload, + params, + headers, + timeout_ms + ); + ELSE + RAISE EXCEPTION 'method argument % is invalid', method; + END CASE; + + INSERT INTO supabase_functions.hooks + (hook_table_id, hook_name, request_id) + VALUES + (TG_RELID, TG_NAME, request_id); + + RETURN NEW; + END + $function$; + -- Supabase super admin + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_functions_admin' + ) + THEN + CREATE USER supabase_functions_admin NOINHERIT CREATEROLE LOGIN NOREPLICATION; + END IF; + END + $$; + GRANT ALL PRIVILEGES ON SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA supabase_functions TO supabase_functions_admin; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA supabase_functions TO supabase_functions_admin; + ALTER USER supabase_functions_admin SET search_path = "supabase_functions"; + ALTER table "supabase_functions".migrations OWNER TO supabase_functions_admin; + ALTER table "supabase_functions".hooks OWNER TO supabase_functions_admin; + ALTER function "supabase_functions".http_request() OWNER TO supabase_functions_admin; + GRANT supabase_functions_admin TO postgres; + -- Remove unused supabase_pg_net_admin role + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_roles + WHERE rolname = 'supabase_pg_net_admin' + ) + THEN + REASSIGN OWNED BY supabase_pg_net_admin TO supabase_admin; + DROP OWNED BY supabase_pg_net_admin; + DROP ROLE supabase_pg_net_admin; + END IF; + END + $$; + -- pg_net grants when extension is already enabled + DO + $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_extension + WHERE extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END + $$; + -- Event trigger for pg_net + CREATE OR REPLACE FUNCTION extensions.grant_pg_net_access() + RETURNS event_trigger + LANGUAGE plpgsql + AS $$ + BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_event_trigger_ddl_commands() AS ev + JOIN pg_extension AS ext + ON ev.objid = ext.oid + WHERE ext.extname = 'pg_net' + ) + THEN + GRANT USAGE ON SCHEMA net TO supabase_functions_admin, postgres, anon, authenticated, service_role; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SECURITY DEFINER; + ALTER function net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + ALTER function net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) SET search_path = net; + REVOKE ALL ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + REVOKE ALL ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) FROM PUBLIC; + GRANT EXECUTE ON FUNCTION net.http_get(url text, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + GRANT EXECUTE ON FUNCTION net.http_post(url text, body jsonb, params jsonb, headers jsonb, timeout_milliseconds integer) TO supabase_functions_admin, postgres, anon, authenticated, service_role; + END IF; + END; + $$; + COMMENT ON FUNCTION extensions.grant_pg_net_access IS 'Grants access to pg_net'; + DO + $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_event_trigger + WHERE evtname = 'issue_pg_net_access' + ) THEN + CREATE EVENT TRIGGER issue_pg_net_access ON ddl_command_end WHEN TAG IN ('CREATE EXTENSION') + EXECUTE PROCEDURE extensions.grant_pg_net_access(); + END IF; + END + $$; + INSERT INTO supabase_functions.migrations (version) VALUES ('20210809183423_update_grants'); + ALTER function supabase_functions.http_request() SECURITY DEFINER; + ALTER function supabase_functions.http_request() SET search_path = supabase_functions; + REVOKE ALL ON FUNCTION supabase_functions.http_request() FROM PUBLIC; + GRANT EXECUTE ON FUNCTION supabase_functions.http_request() TO postgres, anon, authenticated, service_role; +COMMIT; diff --git a/autogpt_platform/db/docker/volumes/functions/hello/index.ts b/autogpt_platform/db/docker/volumes/functions/hello/index.ts new file mode 100644 index 000000000000..f1e20b90e015 --- /dev/null +++ b/autogpt_platform/db/docker/volumes/functions/hello/index.ts @@ -0,0 +1,16 @@ +// Follow this setup guide to integrate the Deno language server with your editor: +// https://deno.land/manual/getting_started/setup_your_environment +// This enables autocomplete, go to definition, etc. + +import { serve } from "https://deno.land/std@0.177.1/http/server.ts" + +serve(async () => { + return new Response( + `"Hello from Edge Functions!"`, + { headers: { "Content-Type": "application/json" } }, + ) +}) + +// To invoke: +// curl 'http://localhost:/functions/v1/hello' \ +// --header 'Authorization: Bearer ' diff --git a/autogpt_platform/db/docker/volumes/functions/main/index.ts b/autogpt_platform/db/docker/volumes/functions/main/index.ts new file mode 100644 index 000000000000..a094010b9dad --- /dev/null +++ b/autogpt_platform/db/docker/volumes/functions/main/index.ts @@ -0,0 +1,94 @@ +import { serve } from 'https://deno.land/std@0.131.0/http/server.ts' +import * as jose from 'https://deno.land/x/jose@v4.14.4/index.ts' + +console.log('main function started') + +const JWT_SECRET = Deno.env.get('JWT_SECRET') +const VERIFY_JWT = Deno.env.get('VERIFY_JWT') === 'true' + +function getAuthToken(req: Request) { + const authHeader = req.headers.get('authorization') + if (!authHeader) { + throw new Error('Missing authorization header') + } + const [bearer, token] = authHeader.split(' ') + if (bearer !== 'Bearer') { + throw new Error(`Auth header is not 'Bearer {token}'`) + } + return token +} + +async function verifyJWT(jwt: string): Promise { + const encoder = new TextEncoder() + const secretKey = encoder.encode(JWT_SECRET) + try { + await jose.jwtVerify(jwt, secretKey) + } catch (err) { + console.error(err) + return false + } + return true +} + +serve(async (req: Request) => { + if (req.method !== 'OPTIONS' && VERIFY_JWT) { + try { + const token = getAuthToken(req) + const isValidJWT = await verifyJWT(token) + + if (!isValidJWT) { + return new Response(JSON.stringify({ msg: 'Invalid JWT' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } catch (e) { + console.error(e) + return new Response(JSON.stringify({ msg: e.toString() }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }) + } + } + + const url = new URL(req.url) + const { pathname } = url + const path_parts = pathname.split('/') + const service_name = path_parts[1] + + if (!service_name || service_name === '') { + const error = { msg: 'missing function name in request' } + return new Response(JSON.stringify(error), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }) + } + + const servicePath = `/home/deno/functions/${service_name}` + console.error(`serving the request with ${servicePath}`) + + const memoryLimitMb = 150 + const workerTimeoutMs = 1 * 60 * 1000 + const noModuleCache = false + const importMapPath = null + const envVarsObj = Deno.env.toObject() + const envVars = Object.keys(envVarsObj).map((k) => [k, envVarsObj[k]]) + + try { + const worker = await EdgeRuntime.userWorkers.create({ + servicePath, + memoryLimitMb, + workerTimeoutMs, + noModuleCache, + importMapPath, + envVars, + }) + return await worker.fetch(req) + } catch (e) { + const error = { msg: e.toString() } + return new Response(JSON.stringify(error), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }) + } +}) diff --git a/autogpt_platform/db/docker/volumes/logs/vector.yml b/autogpt_platform/db/docker/volumes/logs/vector.yml new file mode 100644 index 000000000000..cce46df43dba --- /dev/null +++ b/autogpt_platform/db/docker/volumes/logs/vector.yml @@ -0,0 +1,232 @@ +api: + enabled: true + address: 0.0.0.0:9001 + +sources: + docker_host: + type: docker_logs + exclude_containers: + - supabase-vector + +transforms: + project_logs: + type: remap + inputs: + - docker_host + source: |- + .project = "default" + .event_message = del(.message) + .appname = del(.container_name) + del(.container_created_at) + del(.container_id) + del(.source_type) + del(.stream) + del(.label) + del(.image) + del(.host) + del(.stream) + router: + type: route + inputs: + - project_logs + route: + kong: '.appname == "supabase-kong"' + auth: '.appname == "supabase-auth"' + rest: '.appname == "supabase-rest"' + realtime: '.appname == "supabase-realtime"' + storage: '.appname == "supabase-storage"' + functions: '.appname == "supabase-functions"' + db: '.appname == "supabase-db"' + # Ignores non nginx errors since they are related with kong booting up + kong_logs: + type: remap + inputs: + - router.kong + source: |- + req, err = parse_nginx_log(.event_message, "combined") + if err == null { + .timestamp = req.timestamp + .metadata.request.headers.referer = req.referer + .metadata.request.headers.user_agent = req.agent + .metadata.request.headers.cf_connecting_ip = req.client + .metadata.request.method = req.method + .metadata.request.path = req.path + .metadata.request.protocol = req.protocol + .metadata.response.status_code = req.status + } + if err != null { + abort + } + # Ignores non nginx errors since they are related with kong booting up + kong_err: + type: remap + inputs: + - router.kong + source: |- + .metadata.request.method = "GET" + .metadata.response.status_code = 200 + parsed, err = parse_nginx_log(.event_message, "error") + if err == null { + .timestamp = parsed.timestamp + .severity = parsed.severity + .metadata.request.host = parsed.host + .metadata.request.headers.cf_connecting_ip = parsed.client + url, err = split(parsed.request, " ") + if err == null { + .metadata.request.method = url[0] + .metadata.request.path = url[1] + .metadata.request.protocol = url[2] + } + } + if err != null { + abort + } + # Gotrue logs are structured json strings which frontend parses directly. But we keep metadata for consistency. + auth_logs: + type: remap + inputs: + - router.auth + source: |- + parsed, err = parse_json(.event_message) + if err == null { + .metadata.timestamp = parsed.time + .metadata = merge!(.metadata, parsed) + } + # PostgREST logs are structured so we separate timestamp from message using regex + rest_logs: + type: remap + inputs: + - router.rest + source: |- + parsed, err = parse_regex(.event_message, r'^(?P