diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..1e6332f --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,55 @@ +{ + "mcpServers": { + "github": { + "command": "node", + "args": [ + "scripts/mcp-env.mjs", + "npx", "-y", "@modelcontextprotocol/server-github" + ] + }, + "docker": { + "command": "uv", + "args": ["tool", "run", "mcp-server-docker"] + }, + "context7": { + "command": "npx", + "args": ["-y", "@upstash/context7-mcp@latest"] + }, + "sequential-thinking": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-sequential-thinking"] + }, + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"], + "env": { + "MEMORY_FILE_PATH": ".cursor/memory/memory.json" + } + }, + "supabase": { + "command": "node", + "args": [ + "scripts/mcp-env.mjs", + "npx", "-y", "@supabase/mcp-server-supabase@latest", + "--read-only", + "--project-ref=${SUPABASE_PROJECT_REF}" + ] + }, + "postgres": { + "command": "node", + "args": [ + "scripts/mcp-env.mjs", + "npx", "-y", "@modelcontextprotocol/server-postgres", + "${DATABASE_URL}" + ] + }, + "auth0": { + "command": "npx", + "args": ["-y", "@auth0/auth0-mcp-server", "run"] + }, + "azure": { + "command": "npx", + "args": ["-y", "@azure/mcp@latest", "server", "start"] + } + } +} diff --git a/.cursor/rules/code-quality-gate.mdc b/.cursor/rules/code-quality-gate.mdc new file mode 100644 index 0000000..32e87c4 --- /dev/null +++ b/.cursor/rules/code-quality-gate.mdc @@ -0,0 +1,34 @@ +--- +description: Enforce project conventions when editing agent source code +globs: examples/*/src/**/*.py +alwaysApply: false +--- + +# Code Quality Gate + +When editing Python files in `examples/*/src/`, verify these conventions: + +## FastAPI App (`app.py`) +- Has `/health` endpoint returning `{"status": "ok"}` +- Uses `lifespan` context manager (not `@app.on_event`) +- Calls `setup_tracing()` in lifespan +- All endpoints are `async def` +- Request/response use Pydantic models + +## Agent Nodes +- Type hints on all function signatures and variables +- Uses `verbose_log("AgentName", ...)` for debug output +- State is a `TypedDict` or Pydantic model (not plain dict) +- Agent functions are async: `async def node(state: State) -> dict` +- No bare `except:` -- catch specific exceptions + +## Imports +- `agent_common` imported from workspace (`from agent_common.tracing import ...`) +- No relative imports crossing package boundaries + +## Quick Checklist +- [ ] Type hints on every function +- [ ] `async def` for all agent nodes and endpoints +- [ ] `verbose_log()` calls in agent logic +- [ ] `setup_tracing()` called in app lifespan +- [ ] `/health` endpoint present diff --git a/.cursor/rules/git-workflow.mdc b/.cursor/rules/git-workflow.mdc new file mode 100644 index 0000000..10a4477 --- /dev/null +++ b/.cursor/rules/git-workflow.mdc @@ -0,0 +1,37 @@ +--- +description: Git branching strategy, commit message format, and PR workflow +alwaysApply: true +--- + +# Git Workflow + +## Branching +- `main` -- stable, release-ready code +- `dev` -- integration branch for in-progress work +- Feature branches off `dev` when needed: `feat/NN-short-name` + +## Commit Messages +Use conventional commits with optional scope: + +``` +type(scope): short description + +Optional body explaining motivation. +``` + +Types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci` +Scopes: pattern number or area, e.g. `01`, `common`, `infra`, `ci` + +Examples: +- `feat(02): add community analyst agent with MCP tool calls` +- `test(01): add e2e tests for orchestrator pipeline graph` +- `docs: expand README with pattern progression overview` + +## Pull Requests +- PRs merge `dev` → `main` after a set of related changes +- Use the GitHub MCP (`project-0-agent-patterns-lab-github` / `create_pull_request`) -- `gh` CLI is **installed** and can be used as alternative tool +- Write PR body to a temp file and use `--body-file` if using CLI fallback (PowerShell has no heredocs) + +## PowerShell Caveats +- No heredoc (`<` +- + +## GitHub CLI (`gh`) +- Installed: `gh` v2.88.1, authenticated as `ksopyla` +- Auth scopes: `repo`, `gist`, `read:org`, `admin:public_key` +- Git operations protocol: SSH +- Use `gh` for GitHub operations: PRs, issues, actions, releases, API calls +- Prefer `gh` CLI over the GitHub MCP server when both can do the job -- CLI is faster and more flexible +- Examples: + - `gh pr create --title "..." --body "..."` + - `gh issue list --label bug` + - `gh run list --workflow ci.yml` + - `gh api repos/{owner}/{repo}/actions/runs` for arbitrary API calls ## Repository - GitHub: `git@github.com:ksopyla/agent-patterns-lab.git` -- Branch: `main` +- Branch: `main` and `dev` (for development) - Remote: `origin` -## LLM Providers -- Primary: Azure OpenAI (GPT-5 deployment) -- Secondary: Anthropic (Claude 4.6 Opus) +## Secret Management - Configure via `.env` file (copy from `.env.example`) diff --git a/.cursor/rules/project-vision.mdc b/.cursor/rules/project-vision.mdc new file mode 100644 index 0000000..c6cf94e --- /dev/null +++ b/.cursor/rules/project-vision.mdc @@ -0,0 +1,58 @@ +--- +description: Project vision, narrative arc, and use case for the Agent Design Patterns Library +alwaysApply: true +--- + +# Project Vision + +For the full narrative, story, and architectural philosophy, see `docs/vision.md`. + +**Practical design patterns for distributed multi-agent systems** -- from single-process orchestration to enterprise-grade, cloud-deployed agent architectures. + +Terminology: "Pattern", not "Lesson" or "Tutorial". Folder references: `examples/NN-name/`. + +## Use Case: Crypto Intelligence Platform + +All patterns use a single evolving domain: an intelligence platform for crypto projects with three specialized teams. + +### Team 1: Intelligence (Fundamentals Research) -- built in Patterns 01-04 +- **Research Planner** -- analyzes the request, creates a research plan +- **News Scanner** -- searches for recent news, announcements, partnerships +- **Project Profiler** -- researches project goals, whitepaper, technology, roadmap (from P02) +- **Community Analyst** -- monitors social sentiment, GitHub activity (from P02) +- **Intelligence Compiler** -- synthesizes findings into a structured report + +### Team 2: Technical Analysis -- arrives at Pattern 05 +- **Price Collector** -- current/historical price and volume data +- **Indicator Calculator** -- technical indicators (MA, RSI, MACD, Bollinger) +- **Level Analyst** -- support/resistance levels, key price zones +- **Technical Reporter** -- produces technical analysis summary + +### Team 3: Trading Signals -- arrives at Pattern 06 +- **Signal Synthesizer** -- combines fundamentals + technical analysis +- **Risk Assessor** -- evaluates risk from multiple dimensions +- **Trade Advisor** -- produces buy/sell/hold recommendations with confidence levels + +## Pattern Progression + +Main path: P01 → P02 → P03 → P05 → P06 → P07 → P08 → P09 +Optional enrichment: P04 branches off P03 + +| Pattern | Name | Teams | Architectural focus | +|---------|------|-------|---------------------| +| 01 | Orchestrator Pipeline | Team 1 (3 agents) | StateGraph, tool use, single process | +| 02 | MCP Tool Integration | Team 1 (5 agents) | MCP servers/clients, multi-container | +| 03 | Persistent Memory | Team 1 | Checkpointer, PostgreSQL, threads | +| 04 | Memory Lifecycle (optional) | Team 1 | Refiner agent, TTL, hierarchical memory | +| 05 | Distributed A2A | Team 1 + 2 | A2A protocol, Agent Cards, separate services | +| 06 | Async + Streaming | Team 1 + 2 + 3 | Async tasks, SSE, parallel A2A requests | +| 07 | Cross-Network Auth | All (Team 2 external) | JWT, Auth0 M2M, separate Docker networks | +| 08 | Discovery + Observability | All + Whale Tracker | Agent registry, OpenTelemetry, distributed tracing | +| 09 | Cloud Deployment | All on Azure | Container Apps, Bicep IaC, Managed Identity | + +## Key Architectural Decisions + +- **FastAPI role evolves**: P01 serves REST `POST /run` → P02 hosts MCP endpoints → P05+ hosts A2A JSON-RPC. HTTP is the transport, not the API paradigm. +- **No custom UI**: Claude Code / Claude Desktop / Cursor connect to agents via MCP. Agents expose MCP servers and A2A endpoints, not chat widgets. +- **Each pattern tells a story**: the new team or capability creates a real architectural problem that motivates the pattern. Never introduce a pattern without the narrative reason. +- **Documentation co-located**: each pattern's `README.md` is the single source of truth. diff --git a/.cursor/rules/tech-stack.mdc b/.cursor/rules/tech-stack.mdc index 3193a45..7b42349 100644 --- a/.cursor/rules/tech-stack.mdc +++ b/.cursor/rules/tech-stack.mdc @@ -9,9 +9,17 @@ alwaysApply: true - Python 3.14+, async-first, type hints on all functions and variables - Python versions managed by uv (`uv python install 3.14`), NOT pyenv or system installs - `.python-version` file pins the project to 3.14 -- Use `ruff` for linting and formatting, `mypy --strict` for type checking +- Use `ruff` for linting and formatting - Use `pytest` with `asyncio_mode = "auto"` for tests +## Linting & Type Checking Commands +- Lint: `uv run ruff check .` +- Format check: `uv run ruff format --check .` +- Type check: `uv run python scripts/linting/run_mypy.py` +- Tests: `uv run python scripts/testing/run_test_suite.py` +- **NEVER run `mypy` directly across multiple examples** -- each example has its own `src/` package, so a bare `mypy libs/ examples/` causes "Duplicate module named src" errors. Always use the per-directory wrapper script. +- When editing `.github/workflows/ci.yml`, use the same scripts listed above -- CI and local dev must run identical commands. + ## uv - Use `uv add` to add dependencies (never pip install) - Use `uv run` to execute scripts and commands diff --git a/.cursor/rules/testing-gate.mdc b/.cursor/rules/testing-gate.mdc new file mode 100644 index 0000000..498324a --- /dev/null +++ b/.cursor/rules/testing-gate.mdc @@ -0,0 +1,21 @@ +--- +description: Enforce automated tests before commit and in CI +alwaysApply: true +--- + +# Testing Gate + +## Required Behavior +- When code changes in `examples/` or `libs/`, update tests as needed. +- Keep test structure per example as `tests/unit`, `tests/api`, and `tests/e2e`. + +## Canonical Commands +- Tests: `uv run python scripts/testing/run_test_suite.py` +- Type check: `uv run python scripts/linting/run_mypy.py` +- Never run `mypy` directly across multiple examples (causes duplicate module errors). +- CI (`ci.yml`) must use the same wrapper scripts -- never inline raw tool commands. + +## Skill Trigger +- Use the `tester` skill for test planning, implementation, and maintenance. + + diff --git a/.cursor/settings.json b/.cursor/settings.json new file mode 100644 index 0000000..ff73e66 --- /dev/null +++ b/.cursor/settings.json @@ -0,0 +1,13 @@ +{ + "plugins": { + "supabase": { + "enabled": true + }, + "continual-learning": { + "enabled": true + }, + "context7-plugin": { + "enabled": true + } + } +} diff --git a/.cursor/skills/docker-debugger/SKILL.md b/.cursor/skills/docker-debugger/SKILL.md new file mode 100644 index 0000000..0503e57 --- /dev/null +++ b/.cursor/skills/docker-debugger/SKILL.md @@ -0,0 +1,88 @@ +--- +name: docker-debugger +description: >- + Builds, runs, and debugs Docker Compose setups for examples. Use when Docker + builds fail, containers won't start, inter-container networking breaks, or + when debugging multi-service agent deployments. +--- + +# Docker Debugger + +## When to Use + +Trigger this skill when: +- `docker compose up --build` fails or a container exits unexpectedly +- An agent service can't reach another service (network/DNS issues) +- Debugging multi-container setups in Patterns 05+ +- Investigating image build failures with the base Dockerfile + +## Available MCP Tools + +Use the Docker MCP (`project-0-agent-patterns-lab-docker`) for inspection: + +| Tool | Purpose | +|------|---------| +| `list_containers` | See running/stopped containers and their status | +| `fetch_container_logs` | Read stdout/stderr from a specific container | +| `list_images` | Check built images and tags | +| `list_networks` | Inspect Docker networks for multi-service setups | +| `list_volumes` | Check persistent volumes (PostgreSQL, etc.) | +| `start_container` / `stop_container` | Control individual containers | +| `recreate_container` | Rebuild and restart a single container | + +## Diagnostic Workflow + +1. **Check container status** + - Call `list_containers` to see which containers are running, exited, or restarting + - Note exit codes: `0` = clean stop, `1` = app error, `137` = OOM killed + +2. **Read logs for the failing container** + - Call `fetch_container_logs` with the container name + - Look for: import errors, missing env vars, port conflicts, connection refused + +3. **Verify network connectivity** (multi-service) + - Call `list_networks` to confirm all services share a network + - Service DNS names match the `services:` keys in `docker-compose.yml` + - Common fix: services on different networks can't reach each other + +4. **Check image build** + - Call `list_images` to verify the image was built + - If missing, the build stage likely failed -- re-read build logs + +5. **Fix and rebuild** + - Fix the issue in code or config + - Run `docker compose up --build` to rebuild + +## Project-Specific Build Context + +All examples use the base Dockerfile at `infra/docker/base/Dockerfile.agent`: +- Build context is always the repo root (`../..` from the example) +- `EXAMPLE_DIR` build arg points to the specific example folder +- Multi-stage build: `builder` installs deps, `runtime` copies artifacts +- `.env` file is passed via `env_file` in docker-compose + +## Common Failures + +| Symptom | Likely Cause | Fix | +|---------|-------------|-----| +| `ModuleNotFoundError` | Dependency missing in `pyproject.toml` | `uv add ` in the example, rebuild | +| `Connection refused` on port 8000 | App not binding to `0.0.0.0` | Set `--host 0.0.0.0` in uvicorn command | +| Container restarts in loop | Crash on startup (missing env var) | Check `.env` file, compare with `.env.example` | +| `network X not found` | Compose network not created | Run `docker compose down` then `up` again | +| Build fails at `uv sync` | Lock file out of sync | Run `uv lock` locally first, then rebuild | + +## Shell Commands (PowerShell) + +```powershell +# Build and run from example directory +docker compose up --build + +# Rebuild single service +docker compose up --build agent + +# View logs for a service +docker compose logs -f agent + +# Full reset (remove containers, networks, volumes) +docker compose down -v +``` diff --git a/.cursor/skills/documentation-writer/SKILL.md b/.cursor/skills/documentation-writer/SKILL.md index f66b656..bcfd1ac 100644 --- a/.cursor/skills/documentation-writer/SKILL.md +++ b/.cursor/skills/documentation-writer/SKILL.md @@ -1,47 +1,62 @@ --- name: documentation-writer description: >- - Produces LinkedIn-worthy, PDF-printable documentation with Mermaid diagrams. - Use when creating README files, writing lesson documents, drafting blog posts - for ai.ksopyla.com, creating LinkedIn post drafts, or any documentation request. + Produces professional, PDF-printable documentation with Mermaid diagrams. + Use when creating pattern README files, drafting blog posts for ai.ksopyla.com, + creating LinkedIn post drafts, or any documentation request. --- # Documentation Writer -## README Template +## Pattern README Template -Every example folder README must follow this structure: +Documentation is co-located with code -- each pattern has a comprehensive `README.md` inside its `examples/NN-name/` folder. The README is the single source of truth for each pattern's theory, architecture, implementation, and usage. -```markdown -# Lesson N: [Title] - -> One-sentence summary of what this lesson teaches. +Every pattern README must follow this structure: -## What You'll Learn +```markdown +# Pattern NN: [Name] -- Takeaway 1 -- Takeaway 2 -- Takeaway 3 +> One-sentence summary of the architectural challenge this pattern solves. -## The Problem +## Problem Statement -Describe the real-world scenario. Why does the previous lesson's approach fall short? +What architectural challenge does this pattern solve? Why does the previous +pattern's approach fall short for this new requirement? Ground it in the +crypto intelligence use case narrative. ## Architecture -[Mermaid diagram here -- flowchart, sequence, or state diagram as appropriate] +Mermaid diagram of the system -- show containers, agent nodes, communication +protocols, and data flow. Use ASCII-art diagrams for in-terminal readability +alongside or instead of Mermaid when appropriate. ## Key Concepts -Explain the theory. Use comparison tables for trade-offs. +Bullet list of concepts introduced in this pattern. Each bullet should be +a concept name followed by a one-sentence explanation. -## Implementation +## When to Use / When NOT to Use -### Step 1: [Description] -Annotated code snippet with explanation. +Decision criteria for applying this pattern in production: +- **Use when:** concrete scenarios where this pattern is the right choice +- **Avoid when:** scenarios where a simpler or different pattern is better +- Include trade-off comparison table if multiple approaches exist + +## Prerequisites + +Which patterns must be completed before this one. Link to their READMEs. + +## Implementation Walkthrough +Step-by-step code walkthrough with key snippets. Each step should: +1. State what problem this step solves +2. Show the relevant code (annotated, not full files) +3. Explain design decisions and why alternatives were rejected + +### Step 1: [Description] ### Step 2: [Description] -Continue step by step. +... ## Running the Example @@ -55,24 +70,46 @@ docker compose up --build # Test curl http://localhost:8000/health -curl -X POST http://localhost:8000/run -H "Content-Type: application/json" -d '{"input": "..."}' +curl -X POST http://localhost:8000/run \ + -H "Content-Type: application/json" \ + -d '{"input": "Research the Arbitrum crypto project"}' \`\`\` -## Debug Walkthrough +## What You Should See (Verbose Output) -Show the verbose output with annotations explaining what each agent is doing. +Show the expected verbose/trace output with annotations explaining what each +agent is doing at each step. This section is critical for learning -- readers +should be able to compare their output to this reference. ## Exercises -1. Extend the example in some way -2. Try a variation -3. Compare approaches +2-3 extensions the reader can try, ordered by difficulty: +1. Small modification (add a field, change a parameter) +2. Medium extension (add an agent node, new tool) +3. Ambitious challenge (architectural change, new capability) + +## Trade-offs & Discussion + +Pros, cons, and alternatives. Real-world considerations: +- Performance implications +- Failure modes and recovery +- When to evolve to the next pattern +- Comparison with alternative approaches (table format preferred) ## Further Reading -- [Link to relevant spec or docs] +- Links to LangGraph docs, protocol specs, relevant papers +- Links to prerequisite / next pattern READMEs ``` +## README Content Guidelines + +- **Use case consistency**: All examples use the crypto intelligence platform use case (Teams 1-3). Reference the specific team and agents relevant to the pattern. +- **Self-contained**: A reader should understand the pattern from the README alone, without reading `docs/curriculum.md` first. +- **Code snippets**: Show annotated key parts, not full files. Reference file paths so readers can find the full source. +- **Progressive narrative**: Each README should reference what changed from the previous pattern and why the new pattern is needed. +- **No redundant summaries**: The README replaces any separate lesson document. It should be comprehensive enough to serve as blog source material. + ## Mermaid Diagram Guidelines - Use `graph TD` for architecture overviews (top-down flow) @@ -82,39 +119,5 @@ Show the verbose output with annotations explaining what each agent is doing. - No spaces in node IDs: use `camelCase` or `underscores` - Wrap special characters in labels with double quotes - Do NOT add custom colors or styles -- let the theme handle it +- For multi-service architectures (Pattern 05+), show containers, networks, and protocols clearly -## Lesson Document Format - -Lessons in `docs/lessons/` are longer-form, PDF-printable documents. They expand on the README with: -- Deeper theory and background -- More diagrams showing internal agent architecture -- Comparison with alternative approaches -- "What the Agents Are Doing" section with annotated verbose output -- Screenshots of LangSmith traces (placeholder text until actual screenshots) - -## LinkedIn Post Format - -``` -[Hook -- provocative question or surprising insight, 1-2 lines] - -[3-4 lines of insight, what you learned building this] - -[Key takeaway or actionable advice] - -[CTA: link to blog post or repo] - -#AIAgents #LangGraph #DistributedSystems #AgenticAI -``` - -Keep under 1300 characters. No emojis unless the user requests them. - -## Blog Post Outline - -For ai.ksopyla.com, produce an outline with: -1. Title (SEO-friendly, includes "AI agents" or "LangGraph") -2. Introduction (the problem, why it matters) -3. Sections matching the lesson structure -4. Code snippets (key parts, not full files) -5. Diagrams (embed Mermaid or export as images) -6. Conclusion with next steps -7. Link to the GitHub example diff --git a/.cursor/skills/example-scaffolder/SKILL.md b/.cursor/skills/example-scaffolder/SKILL.md index e4ec442..866e63c 100644 --- a/.cursor/skills/example-scaffolder/SKILL.md +++ b/.cursor/skills/example-scaffolder/SKILL.md @@ -32,8 +32,12 @@ examples/NN-name/ │ ├── app.py # FastAPI application │ └── config.py # Settings and env var loading └── tests/ - ├── __init__.py - └── test_agents.py + ├── unit/ + │ └── test_*.py + ├── api/ + │ └── test_*.py + └── e2e/ + └── test_*.py ``` ## pyproject.toml Template @@ -159,5 +163,5 @@ After scaffolding, verify: - [ ] `src/app.py` has `/health` endpoint - [ ] `src/app.py` calls `setup_tracing()` in lifespan - [ ] Agent nodes use `verbose_log()` for debug output -- [ ] `tests/test_agents.py` has at least a placeholder test +- [ ] `tests/unit`, `tests/api`, and `tests/e2e` all exist - [ ] `README.md` follows the documentation-writer template diff --git a/.cursor/skills/tester/SKILL.md b/.cursor/skills/tester/SKILL.md new file mode 100644 index 0000000..0cc8d92 --- /dev/null +++ b/.cursor/skills/tester/SKILL.md @@ -0,0 +1,85 @@ +--- +name: tester +description: >- + Builds and maintains a complete test strategy for examples: unit, API, and + end-to-end tests. Use when adding features, refactoring agents, creating new + examples, reviewing test coverage, or preparing changes for commit/PR. +--- + +# Tester + +## When to Use + +Trigger this skill when: +- Adding or changing code in `examples/*/src/` or `libs/*/src/` +- Creating a new example folder +- Improving confidence before commit or pull request +- Investigating regressions in pipeline flow or API behavior + +## Safety Rules +- Do not rely on live LLM providers in tests. +- Prefer deterministic tests with mocks/stubs. +- Keep API and e2e tests in CI-safe form (no external services required unless explicitly marked and isolated). +- When mocking LangChain/MCP tool objects, configure `.ainvoke` explicitly: + `mock_tool.ainvoke = AsyncMock(return_value='...')`. + Do NOT use `AsyncMock(return_value='...')` alone -- that configures the mock + as a callable, but agent code calls `tool.ainvoke(...)`, which creates an + unconfigured child mock returning a MagicMock instead of the expected value. + +## Test Architecture Standard + +For every example, keep tests in: + +```text +examples/NN-name/tests/ +├── unit/ +│ └── test_*.py +├── api/ +│ └── test_*.py +└── e2e/ + └── test_*.py +``` + +Coverage intent: +- **unit**: isolate pure logic and individual agent nodes with mocks/stubs +- **api**: validate FastAPI endpoints (`/health`, request validation, response model) +- **e2e**: validate graph orchestration and cross-node state flow + +## Default Workflow + +1. **Discover impacted examples** + - Check changed files under `examples/` and `libs/`. +2. **Update tests by scope** + - Behavior logic changed -> update `tests/unit/` + - Endpoint schema/flow changed -> update `tests/api/` + - Graph wiring/agent sequence changed -> update `tests/e2e/` +3. **Run complete suite** + - `python scripts/testing/run_test_suite.py` +4. **Fix failures and re-run** + - Repeat until green. + +## Commands + +- Full test suite with coverage: + - `python scripts/testing/run_test_suite.py` +- Faster local run without coverage: + - `python scripts/testing/run_test_suite.py --no-coverage` +- Direct pytest fallback: + - `uv run pytest` + +## CI and Commit Gate + +- Local commit gate is enforced via `.pre-commit-config.yaml`: + - hook id: `run-full-test-suite` +- Remote gate is enforced via `.github/workflows/ci.yml`: + - runs on push to `main` and pull requests targeting `main` + - executes unit + api + e2e tests with coverage + +## Quality Checklist + +Before marking testing work complete: +- [ ] Each changed example has `unit`, `api`, and `e2e` tests when applicable +- [ ] No test performs real LLM API calls (use mocks/stubs) +- [ ] API tests validate both success and failure paths +- [ ] E2E tests verify orchestration order and state handoff +- [ ] `python scripts/testing/run_test_suite.py` passes locally diff --git a/.env.example b/.env.example index bbff25d..f6d92bf 100644 --- a/.env.example +++ b/.env.example @@ -16,5 +16,11 @@ AUTH0_CLIENT_ID= AUTH0_CLIENT_SECRET= AUTH0_AUDIENCE= +# MCP Servers +GITHUB_PERSONAL_ACCESS_TOKEN= +SUPABASE_ACCESS_TOKEN= +SUPABASE_PROJECT_REF= +DATABASE_URL= + # Debug VERBOSE=true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 43db58a..248aa37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,25 @@ on: branches: [main] jobs: + pre-commit-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + with: + version: "latest" + + - name: Set up Python + run: uv python install 3.14 + + - name: Install dependencies + run: uv sync --all-packages + + - name: Run pre-commit pipeline + run: uv run pre-commit run --all-files + lint-and-test: runs-on: ubuntu-latest steps: @@ -30,10 +49,10 @@ jobs: run: uv run ruff format --check . - name: Type check - run: uv run mypy libs/ examples/ --ignore-missing-imports + run: uv run python scripts/linting/run_mypy.py - - name: Test - run: uv run pytest --tb=short -q + - name: Test (unit + api + e2e with coverage) + run: uv run python scripts/testing/run_test_suite.py env: VERBOSE: "false" diff --git a/.gitignore b/.gitignore index b7faf40..8d6fe45 100644 --- a/.gitignore +++ b/.gitignore @@ -200,6 +200,8 @@ cython_debug/ # refer to https://docs.cursor.com/context/ignore-files .cursorignore .cursorindexingignore +.cursor/memory/ +.cursor/hooks/state/ # Marimo marimo/_static/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 354d1bd..6cbc8b7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,11 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.9.9 + rev: v0.15.6 hooks: - id: ruff args: [--fix] - id: ruff-format - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.14.1 - hooks: - - id: mypy - additional_dependencies: [] - args: [--ignore-missing-imports] - pass_filenames: false - entry: mypy libs/ examples/ - - repo: https://github.com/Yelp/detect-secrets rev: v1.5.0 hooks: diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..ecdd8cd --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,127 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "DiscordBotTokenDetector" + }, + { + "name": "GitHubTokenDetector" + }, + { + "name": "GitLabTokenDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "IPPublicDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "OpenAIDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "PypiTokenDetector" + }, + { + "name": "SendGridDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TelegramBotTokenDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies", + "min_level": 2 + }, + { + "path": "detect_secrets.filters.heuristic.is_indirect_reference" + }, + { + "path": "detect_secrets.filters.heuristic.is_likely_id_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_lock_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_not_alphanumeric_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_potential_uuid" + }, + { + "path": "detect_secrets.filters.heuristic.is_prefixed_with_dollar_sign" + }, + { + "path": "detect_secrets.filters.heuristic.is_sequential_string" + }, + { + "path": "detect_secrets.filters.heuristic.is_swagger_file" + }, + { + "path": "detect_secrets.filters.heuristic.is_templated_secret" + } + ], + "results": {}, + "generated_at": "2026-03-15T18:12:18Z" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..236edcb --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,26 @@ +# AGENTS.md + +Automatically maintained by continual learning. Do not edit manually. + +## Learned User Preferences + +- Use PowerShell syntax for all terminal commands (Windows 11 environment) +- Use `uv` for all Python package and environment management, never pip or pyenv +- Use conventional commit format: `type(scope): description` +- Use GitHub MCP tools for PR/issue operations (`gh` CLI is installed and can be used as alternative tool or for more complex operations) +- Write multi-line strings to temp files in PowerShell (no heredoc support) +- Prefer async-first Python with type hints on all functions +- Run tests with `uv run python scripts/testing/run_test_suite.py` or `uv run pytest` +- Run type checks with `uv run python scripts/linting/run_mypy.py`, never raw `mypy` across examples + +## Learned Workspace Facts + +- Base Docker image: `infra/docker/base/Dockerfile.agent` (multi-stage build) +- Each example is a uv workspace member with its own `pyproject.toml` +- Shared library: `libs/common/` imported as `agent_common` +- Environment variables loaded from `.env` (copy `.env.example`) +- Python version pinned to 3.14 via `.python-version` +- Project repo: `git@github.com:ksopyla/agent-patterns-lab.git` +- Branches: `dev` (working), `main` (stable) +- Monorepo: multiple `src/` packages exist across examples -- tools like mypy must run per-directory to avoid duplicate module conflicts +- CI commands in `.github/workflows/ci.yml` must use the same wrapper scripts as local dev (single source of truth) diff --git a/docs/CHANGELOG.md b/CHANGELOG.md similarity index 100% rename from docs/CHANGELOG.md rename to CHANGELOG.md diff --git a/Makefile b/Makefile index 5cdc03d..f1b63d6 100644 --- a/Makefile +++ b/Makefile @@ -2,11 +2,12 @@ setup: uv sync --all-packages + uv run pre-commit install --install-hooks --hook-type pre-commit --hook-type commit-msg lint: uv run ruff check . uv run ruff format --check . - uv run mypy libs/ examples/ + uv run mypy libs/ examples/ --ignore-missing-imports --exclude "(^|/)tests/" --disable-error-code=misc --disable-error-code=unused-ignore test: uv run pytest @@ -15,7 +16,7 @@ fmt: uv run ruff format . uv run ruff check --fix . -# Usage: make example EX=01-multi-agent-single-system +# Usage: make example EX=01-orchestrator-pipeline example: docker compose -f examples/$(EX)/docker-compose.yml up --build diff --git a/README.md b/README.md index 2cdadab..fd49ebf 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,280 @@ -# Agent Patterns Lab +
-Practical design patterns, protocols, and architectures for real-world AI agents. +# Agent Design Patterns Lab -A progressive, hands-on learning repository that takes you from a single LangGraph agent to a fully distributed, authenticated, cloud-deployed multi-agent system. +[![Python 3.14+](https://img.shields.io/badge/python-3.14+-3776AB?style=flat&logo=python&logoColor=white)](https://www.python.org) +[![LangGraph](https://img.shields.io/badge/LangGraph-0.4+-1C3C3C?style=flat&logo=langchain&logoColor=white)](https://github.com/langchain-ai/langgraph) +[![FastAPI](https://img.shields.io/badge/FastAPI-0.115+-009688?style=flat&logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com) +[![Docker](https://img.shields.io/badge/Docker-Compose-2496ED?style=flat&logo=docker&logoColor=white)](https://docs.docker.com/compose/) +[![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg?style=flat)](LICENSE) +[![CI](https://github.com/ksopyla/agent-patterns-lab/actions/workflows/ci.yml/badge.svg)](https://github.com/ksopyla/agent-patterns-lab/actions/workflows/ci.yml) -## Tech Stack +**Practical design patterns for distributed multi-agent systems** +
+*From a single LangGraph pipeline to enterprise-grade, cloud-deployed agent architectures.* + +[Curriculum](docs/curriculum.md) · [Vision & Roadmap](docs/vision.md) · [Blog](https://ai.ksopyla.com) · [Changelog](docs/CHANGELOG.md) + +
+ +--- + +## The Problem + +The AI agent landscape today is where microservices were in 2014. Frameworks multiply weekly -- LangGraph, CrewAI, AutoGen -- but few teams have shipped production multi-agent systems. The gap isn't tooling. **The gap is architectural knowledge**: how do you actually structure, deploy, and operate agents that work together across services, trust boundaries, and cloud environments? + +This project closes that gap with **9 design patterns**, each solving a named architectural problem with working code you can run, study, and adapt. + +## The Approach + +Not tutorials. **Design patterns** -- in the tradition of Gang of Four, Cloud Design Patterns, and Microservices Patterns. Each pattern: + +- Solves a **real architectural problem** that the previous pattern cannot handle +- Has clear **"when to use / when to avoid"** criteria +- Shows **trade-offs**, not just happy paths +- Builds on the previous pattern -- you experience the limitation before learning the solution + +The progression itself tells a story. In Pattern 01 you build a familiar `POST /run` REST endpoint. By Pattern 05 that same FastAPI server hosts A2A JSON-RPC protocol endpoints. By Pattern 09 agents discover each other dynamically, authenticate via JWT, and deploy as independent cloud services. HTTP is still the transport -- but what travels over it has fundamentally changed. + +> **This is the Software 2.0 → 3.0 transition, demonstrated through code.** + +## The Story + +Abstract patterns are hard to internalize. Concrete stories stick. + +All nine patterns share a single, evolving domain: **a crypto intelligence platform** with three specialized teams that emerge as complexity demands them. Each team's arrival creates a genuine architectural challenge that motivates the next pattern. + +### Act 1 — One Team, Growing Capabilities +Patterns 01-04 + +You are **Team 1: Intelligence**. Three agents research crypto projects inside a single LangGraph pipeline. It works -- until you realize tools are hardcoded, every request starts from scratch, and memory grows unbounded. Each limitation drives the next pattern: MCP for standardized tools, PostgreSQL-backed checkpointers for persistence, a Memory Refiner for lifecycle management. + +### Act 2 — Teams Multiply, Protocols Emerge +Patterns 05-06 + +**Team 2: Technical Analysis** arrives -- a separate service, separate codebase, separate container. You can't `import` their code. You need a protocol. A2A enters. Then **Team 3: Trading Signals** needs data from *both* teams simultaneously. Sequential calls take 50+ seconds. Async communication and SSE streaming become the only viable path. + +### Act 3 — Enterprise Reality +Patterns 07-09 + +Team 2 moves to an external partner. Implicit trust is gone -- JWT authentication on every call. New agents appear and need dynamic discovery. Three teams deploy to Azure as independent Container Apps with Infrastructure as Code, Managed Identity, and per-team CI/CD pipelines. + +**From a single Python file to a cloud-deployed, authenticated, observable, dynamically-discoverable multi-agent system.** + +## Design Patterns -- **Python 3.14+** with **uv** for package and Python version management -- **LangGraph** for agent orchestration -- **FastAPI** for agent HTTP endpoints -- **Docker Compose** for local multi-container environments -- **LangSmith** for tracing and observability -- **Auth0** for OIDC-based agent authentication (Lesson 4+) -- **Azure Container Apps** for cloud deployment (Lesson 6+) -- **A2A / MCP** protocols for agent communication + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PatternWhat It SolvesKey Concepts
Foundation Tier · Single Docker network, one team, agent internals
01Orchestrator PipelineDecomposing tasks across specialized agentsLangGraph StateGraph, tool use, LangSmith tracing
02MCP Tool IntegrationStandardized tool access for agents & AI clientsMCP servers/clients, Claude Code integration
03Persistent MemoryRemembering across conversationsCheckpointer, PostgreSQL, thread management
04Memory Lifecycle optionalManaging growing knowledge basesMemory refiner, fact TTL, hierarchical memory
Distribution Tier · Multi-service, multi-team, real distributed systems
05Distributed A2ACross-team agent communicationA2A protocol, Agent Cards, JSON-RPC
06Async & StreamingNon-blocking multi-team coordinationAsync A2A, SSE streaming, parallel requests
07Cross-Network AuthSecuring agents across trust boundariesAuth0 OIDC, JWT, M2M tokens, zero-trust
Enterprise Tier · Production readiness, cloud deployment
08Discovery & ObservabilityFinding agents and monitoring the systemAgent registry, OpenTelemetry, distributed tracing
09Cloud DeploymentProduction infrastructure on AzureContainer Apps, Bicep IaC, Managed Identity, CI/CD
-## Curriculum +### Why Each Transition Matters -### Phase 1: Learning Agent Patterns (8 Lessons) +Every pattern exists because the previous one creates a real limitation: -| # | Lesson | Key Concepts | -|---|--------|-------------| -| 1 | [Multi-Agent Single System](examples/01-multi-agent-single-system/) | LangGraph StateGraph, orchestrator pattern, LangSmith tracing | -| 2 | [Memory and External Services](examples/02-memory-and-external-services/) | MCP protocol, Supabase, persistent state, tool abstraction | -| 3 | [Distributed Agents Communication](examples/03-distributed-agents-communication/) | Separate containers, A2A protocol, Agent Cards | -| 4 | [Cross-Network Authentication](examples/04-cross-network-authentication/) | Auth0 OIDC, M2M tokens, JWT validation | -| 5 | [Agent Discovery](examples/05-agent-discovery/) | Registry patterns, Agent Cards, enterprise governance | -| 6 | [Azure Deployment](examples/06-azure-deployment/) | Bicep IaC, Container Apps, CI/CD pipeline | -| 7 | [UI and Observability](examples/07-ui-and-observability/) | Chat UI, LangSmith dashboard, OpenTelemetry | -| 8 | [Full Stack Integration](examples/08-full-stack-integration/) | All patterns combined, production reference architecture | +``` +P01 ─── Hardcoded tools can't be shared ──────────────── P02 +P02 ─── Every request starts from scratch ────────────── P03 +P03 ─┬─ Memory grows unbounded ──────────────────────── P04 (optional) + └─ A second team arrives, can't import their code ─ P05 +P05 ─── Third team needs both, sequential is too slow ── P06 +P06 ─── Team 2 moves to external partner, no trust ──── P07 +P07 ─── New agents appear, consumers need code changes ─ P08 +P08 ─── Docker Compose doesn't work in production ───── P09 +``` + +## Three Teams, One Platform + +``` + ┌─────────────────────────┐ ┌─────────────────────────┐ ┌─────────────────────────┐ + │ TEAM 1: INTELLIGENCE │ │ TEAM 2: TECHNICAL │ │ TEAM 3: TRADING │ + │ (Patterns 01-04) │ │ ANALYSIS (Pattern 05+) │ │ SIGNALS (Pattern 06+) │ + │ │ │ │ │ │ + │ Research Planner │ │ Price Collector │ │ Signal Synthesizer │ + │ News Scanner │ │ Indicator Calculator │ │ Risk Assessor │ + │ Project Profiler │ │ Level Analyst │ │ Trade Advisor │ + │ Community Analyst │ │ Technical Reporter │ │ │ + │ Intelligence Compiler │ │ │ │ │ + └────────────┬────────────┘ └────────────┬────────────┘ └────────────┬────────────┘ + │ │ │ + │ A2A Protocol │ A2A Protocol │ + └────────────────────────────┴────────────────────────────┘ +``` -### Phase 2: Conversational Tutoring System (Planned) +- **Team 1** researches fundamentals -- news, team, roadmap, community sentiment +- **Team 2** crunches numbers -- price action, indicators, support/resistance levels +- **Team 3** combines both into actionable trading signals with confidence levels -Applies Phase 1 patterns to a real-world distributed system with cross-team agent collaboration. See [docs/phase2/README.md](docs/phase2/README.md). +Each team deploys independently, communicates via A2A protocol, and authenticates across trust boundaries. ## Quick Start ```bash -# Install uv (if not already installed) -# https://docs.astral.sh/uv/getting-started/installation/ - -# Clone and setup git clone https://github.com/ksopyla/agent-patterns-lab.git cd agent-patterns-lab cp .env.example .env -# Fill in your API keys in .env +# Fill in your API keys (Azure OpenAI or Anthropic, LangSmith) -# Install all dependencies +# Install dependencies make setup -# Run an example (e.g., lesson 1) -make example EX=01-multi-agent-single-system +# Run Pattern 01 +make example EX=01-orchestrator-pipeline + +# Verify it's running +curl http://localhost:8000/health + +# Submit a research request +curl -X POST http://localhost:8000/run \ + -H "Content-Type: application/json" \ + -d '{"input": "Research the Arbitrum crypto project"}' ``` +### Prerequisites + +- **Python 3.14+** (managed via [uv](https://docs.astral.sh/uv/)) +- **Docker** and **Docker Compose** +- **API keys**: Azure OpenAI or Anthropic (for LLM), LangSmith (for tracing) + ## Project Structure ``` agent-patterns-lab/ -├── examples/ # One folder per lesson, each self-contained -├── libs/common/ # Shared utilities (LLM config, tracing, logging) -├── docs/ # Curriculum, lessons, changelog -├── infra/ # Docker base images, Azure Bicep, Vercel config -├── ui/ # Chat UI (Lesson 7+) -├── .github/ # CI/CD workflows, PR templates -└── .cursor/ # Cursor rules and skills for AI-assisted development +├── examples/ # One folder per pattern (self-contained) +│ ├── 01-orchestrator-pipeline/ +│ │ ├── README.md # Pattern documentation (theory + walkthrough) +│ │ ├── pyproject.toml +│ │ ├── docker-compose.yml +│ │ ├── src/ +│ │ └── tests/ +│ │ ├── unit/ +│ │ ├── api/ +│ │ └── e2e/ +│ ├── 02-mcp-tool-integration/ +│ └── ... +├── libs/common/ # Shared utilities (agent_common package) +│ └── src/agent_common/ # LLM config, tracing, MCP, A2A, auth helpers +├── docs/ +│ ├── curriculum.md # Technical pattern-by-pattern breakdown +│ ├── vision.md # Full narrative, philosophy, and roadmap +│ └── CHANGELOG.md +├── infra/ # Docker base images, Azure Bicep templates +└── .github/ # CI/CD workflows, PR templates ``` -## Verbose / Debug Mode +## Verbose Mode + +Every pattern supports `VERBOSE=true` (set in `.env`), which logs: -Every example supports `VERBOSE=true` (set in `.env`) which logs: - Agent reasoning steps with timestamps -- Tool call inputs/outputs +- Tool call inputs and outputs - Inter-agent message payloads - LangSmith trace IDs for quick lookup -## Blog +This is a first-class feature, not an afterthought. Reading verbose output is how you learn what agents are actually doing. + +## Testing + +Each pattern maintains three test tiers: + +- **`tests/unit/`** -- individual agent nodes with mocked LLM responses +- **`tests/api/`** -- HTTP/protocol endpoints with mocked agent graph +- **`tests/e2e/`** -- full pipeline with mocked LLM (graph compilation, state flow) + +```bash +# Run full test suite +python scripts/testing/run_test_suite.py + +# Install pre-commit hooks +uv run pre-commit install --install-hooks --hook-type pre-commit --hook-type commit-msg +``` + +## Tech Stack + +| Layer | Technology | Role | +|-------|-----------|------| +| Language | Python 3.14+ / uv | Package and version management | +| Orchestration | LangGraph | Agent state graphs with typed state | +| Server | FastAPI | HTTP/protocol endpoints (REST → MCP → A2A) | +| Infrastructure | Docker Compose | Local multi-container environments | +| Observability | LangSmith | Tracing, debugging, performance monitoring | +| Tools | MCP | Standardized tool access (Pattern 02+) | +| Communication | A2A | Agent-to-agent protocol (Pattern 05+) | +| Authentication | Auth0 | OIDC-based M2M auth (Pattern 07+) | +| Cloud | Azure Container Apps | Production deployment (Pattern 09) | + +## Further Reading -Detailed write-ups for each lesson at [ai.ksopyla.com](https://ai.ksopyla.com). +- **[Full Curriculum](docs/curriculum.md)** -- detailed technical breakdown of each pattern with architecture diagrams +- **[Vision & Roadmap](docs/vision.md)** -- the complete narrative, architectural philosophy, and future direction +- **[Blog](https://ai.ksopyla.com)** -- in-depth write-ups for each pattern +- **[Changelog](docs/CHANGELOG.md)** -- what changed and when ## License -MIT +[MIT](LICENSE) -- built by [Krzysztof Sopyła](https://ai.ksopyla.com) diff --git a/docs/curriculum.md b/docs/curriculum.md index a499427..f15a745 100644 --- a/docs/curriculum.md +++ b/docs/curriculum.md @@ -1,175 +1,474 @@ -# Agent Patterns Lab -- Curriculum +# Agent Design Patterns -- Curriculum -## Overview +A progressive, hands-on curriculum that takes you from a single LangGraph pipeline to a fully distributed, authenticated, cloud-deployed multi-agent system. Every pattern builds on the previous one, with working code, Docker Compose for local execution, LangSmith tracing, and verbose debug output. -A progressive learning path from single-agent basics to fully distributed, authenticated, cloud-deployed multi-agent systems. Every lesson builds on the previous one, with working code, Docker Compose for local execution, LangSmith tracing, and verbose debug output. +## Domain: Crypto Intelligence Platform -## Phase 1: Learning Agent Patterns (8 Lessons) +All patterns share a single compelling domain -- **crypto project intelligence**. Three specialized teams emerge as complexity grows: -### Core Patterns (Lessons 1-3): Single Docker Network, No Auth +### Team 1: Intelligence (Fundamentals Research) -These lessons focus on multi-agent fundamentals. All agents run on a single Docker network. No authentication overhead -- pure focus on agent logic, communication, and patterns. +Built in Patterns 01-04. Focuses on non-technical, qualitative signals. + +| Agent | Responsibility | +|-------|---------------| +| Research Planner | Analyzes the crypto project request, creates a structured research plan | +| News Scanner | Searches the web for recent news, announcements, partnerships | +| Project Profiler | Researches project goals, whitepaper, technology, roadmap, team/founders | +| Community Analyst | Monitors X/Twitter sentiment, community discussions, GitHub activity | +| Intelligence Compiler | Synthesizes all findings into a structured fundamentals report | + +### Team 2: Technical Analysis + +Introduced in Pattern 05. Focuses on price-based, quantitative analysis. + +| Agent | Responsibility | +|-------|---------------| +| Price Collector | Gets current and historical price/volume data via MCP (CoinGecko) | +| Indicator Calculator | Computes technical indicators (MA, RSI, MACD, Bollinger Bands) | +| Level Analyst | Identifies support/resistance levels, key price zones | +| Technical Reporter | Produces a technical analysis summary | + +### Team 3: Trading Signals + +Introduced in Pattern 06. Consumes output from both Team 1 and Team 2. + +| Agent | Responsibility | +|-------|---------------| +| Signal Synthesizer | Combines fundamentals intelligence + technical analysis | +| Risk Assessor | Evaluates risk (volatility, market conditions, project health) | +| Trade Advisor | Produces actionable buy/sell/hold recommendations with confidence levels | + +--- + +## Pattern Progression + +```mermaid +graph TD + subgraph foundation ["Foundation Tier"] + P01["P01: Orchestrator Pipeline"] + P02["P02: MCP Tool Integration"] + P03["P03: Persistent Memory"] + P04["P04: Memory Lifecycle\n(enrichment)"] + end + subgraph distribution ["Distribution Tier"] + P05["P05: Distributed A2A"] + P06["P06: Async + Streaming"] + end + subgraph enterprise ["Enterprise Tier"] + P07["P07: Cross-Network Auth"] + P08["P08: Discovery + Observability"] + P09["P09: Cloud Deployment"] + end + P01 --> P02 + P02 --> P03 + P03 --> P04 + P03 --> P05 + P04 -.-> P05 + P05 --> P06 + P06 --> P07 + P07 --> P08 + P08 --> P09 +``` + +**Main path**: P01 -> P02 -> P03 -> P05 -> P06 -> P07 -> P08 -> P09 + +**Optional enrichment**: P04 branches off P03 (can be skipped without breaking the progression) + +**Team introduction timeline**: + +- Patterns 01-04: Team 1 only (single service, growing capabilities) +- Pattern 05: Team 2 arrives (2 services, A2A communication) +- Pattern 06+: Team 3 arrives (3 services, full distributed system) --- -### Lesson 1: Multi-Agent System as a Single System +## Foundation Tier (Patterns 01-04) + +Focus: agent internals -- orchestration, tools, memory. All run in a single Docker network with no authentication overhead. + +--- + +### Pattern 01: Orchestrator Pipeline + +**Folder:** `examples/01-orchestrator-pipeline/` + +**Goal:** Decompose a complex research task across multiple specialized agents within a single LangGraph StateGraph, exposed via FastAPI, with LangSmith tracing and verbose debug output. + +**What it solves:** A single monolithic LLM prompt tries to plan, research, and write all at once, producing shallow and inconsistent results. The orchestrator pattern splits responsibility across focused agents that each do one thing well. + +**Team focus:** Team 1 (Intelligence) -- first 3 agents as a minimal viable pipeline. + +**Agents:** + +| Agent | Role | Tool | +|-------|------|------| +| Research Planner | Breaks down "Research project X" into subtasks | None (LLM only) | +| News Scanner | Searches the web for recent news and project info | DuckDuckGo web search | +| Intelligence Compiler | Synthesizes findings into a structured report | None (LLM only) | -**Folder:** `examples/01-multi-agent-single-system/` +**Architecture:** -**Goal:** Build a multi-agent system where 3 agents (planner, researcher, writer) collaborate within a single LangGraph StateGraph, exposed via FastAPI, with LangSmith tracing and verbose debug output. +```mermaid +graph TD + User["User Request\n(POST /run)"] --> FastAPI + FastAPI --> StateGraph + subgraph StateGraph ["LangGraph StateGraph"] + Planner["Research Planner\n(creates research plan)"] + Scanner["News Scanner\n(web search + analysis)"] + Compiler["Intelligence Compiler\n(structured report)"] + Planner --> Scanner + Scanner --> Compiler + end + Compiler --> Response["Intelligence Report\n(JSON)"] + StateGraph -.->|traces| LangSmith +``` **Key concepts:** -- LangGraph StateGraph with typed state + +- LangGraph StateGraph with TypedDict state - Orchestrator pattern (single graph coordinates multiple agent nodes) +- Simple tool use (DuckDuckGo web search as a direct tool call) - LangSmith tracing setup and trace inspection - Verbose mode for learning/debugging -- Docker Compose for local execution +- Docker Compose single-container deployment +- FastAPI as a simple trigger endpoint (Software 2.0 entry point) + +**Use case example:** "Research the Arbitrum crypto project" -- plan the research, scan the web for news and project info, compile into a structured intelligence report. -**Prerequisites:** Python 3.12+, Docker, uv, API keys (Azure OpenAI or Anthropic), LangSmith account +**Prerequisites:** Python 3.14+, Docker, uv, API keys (Azure OpenAI or Anthropic), LangSmith account --- -### Lesson 2: Memory and External Services via MCP +### Pattern 02: MCP Tool Integration -**Folder:** `examples/02-memory-and-external-services/` +**Folder:** `examples/02-mcp-tool-integration/` -**Goal:** Add persistent memory and external service integration using MCP (Model Context Protocol). Agent reads/writes structured data through an MCP server backed by a database. +**Goal:** Give agents standardized access to external tools and data sources via the Model Context Protocol (MCP). Build a custom MCP server, connect agents as MCP clients, and show how Claude Code can use the same tools. -**Key concepts:** -- MCP server and client architecture -- Persistent conversation history and state checkpointing -- Tool abstraction (agent doesn't know/care about the underlying database) -- Supabase as a local Docker-based database -- Multi-container Docker Compose +**What it solves:** In Pattern 01, tools are hardcoded Python function calls. This doesn't scale -- if you want to share tools across agents, teams, or even with external AI clients (Claude Code, Cursor), you need a standard protocol. MCP provides that abstraction layer. -**Builds on:** Lesson 1 +**Team focus:** Team 1 (Intelligence) -- expands to full 5-agent lineup with specialized MCP tools. ---- +**Agents:** -### Lesson 3: Distributed Agents in Separate Containers +| Agent | Role | MCP Tools Used | +|-------|------|---------------| +| Research Planner | Creates research plan | None | +| News Scanner | Web search for news | web-search MCP server | +| Project Profiler | Project info, team, roadmap | crypto-data + web-search MCP | +| Community Analyst | GitHub activity, social signals | crypto-data MCP | +| Intelligence Compiler | Synthesizes all outputs | None | -**Folder:** `examples/03-distributed-agents-communication/` +**Architecture:** -**Goal:** Split agents into separate Docker containers (simulating different teams), communicate via A2A protocol. Compare direct HTTP vs A2A. +```mermaid +graph TD + User["User / Claude Code"] --> FastAPI["Agent Service\n(FastAPI :8000)"] + FastAPI --> Pipeline["LangGraph Pipeline\n(5 agents)"] + Pipeline -->|MCP client| CryptoMCP["crypto-data MCP Server\n(:8001)"] + Pipeline -->|MCP client| WebMCP["web-search MCP Server\n(:8002)"] + CryptoMCP --> CoinGecko["CoinGecko API"] + WebMCP --> DDG["DuckDuckGo"] + ClaudeCode["Claude Code\n(MCP client)"] -->|MCP| CryptoMCP + ClaudeCode -->|MCP| WebMCP +``` **Key concepts:** -- A2A (Agent-to-Agent) protocol basics -- Agent Cards for capability advertisement -- Separate FastAPI services per agent -- Docker networking (same network, no auth) -- Communication pattern comparison: direct HTTP vs A2A -**Builds on:** Lessons 1-2 +- MCP server implementation (exposing tools as MCP resources) +- MCP client in LangGraph agents (via `langchain-mcp-adapters`) +- Tool abstraction: agents don't know/care about the underlying API +- Multi-container Docker Compose (agent + MCP servers) +- Claude Code integration: configure Claude Code to connect to the same MCP servers on localhost +- Free crypto data via CoinGecko API (no API key required for basic endpoints) +- Software 3.0 principle: standardized tool access replaces bespoke integrations + +**Builds on:** Pattern 01 --- -### Security + Discovery (Lessons 4-5): Cross-Network +### Pattern 03: Persistent Memory -These lessons introduce authentication and discovery for agents that cross trust boundaries. +**Folder:** `examples/03-persistent-memory/` ---- +**Goal:** Add persistent state across conversations using LangGraph's checkpointer backed by PostgreSQL. When a user asks about a crypto project a second time, the system remembers previous research and provides incremental updates instead of starting from scratch. + +**What it solves:** In Patterns 01-02, every request starts fresh. For a research platform, this wastes tokens and time -- if you researched Arbitrum yesterday, you should build on that knowledge, not repeat it. -### Lesson 4: Cross-Network Authentication with Auth0 +**Team focus:** Team 1 (Intelligence) -- same 5 agents, now with persistent memory. -**Folder:** `examples/04-cross-network-authentication/` +**Architecture:** -**Goal:** Secure agent-to-agent communication across separate Docker networks using Auth0 as a shared OIDC provider. +```mermaid +graph TD + User --> FastAPI["Agent Service\n(FastAPI :8000)"] + FastAPI --> Pipeline["LangGraph Pipeline\n+ Checkpointer"] + Pipeline --> PG["PostgreSQL\n(conversation state + research cache)"] + Pipeline --> CryptoMCP["crypto-data MCP\n(:8001)"] + Pipeline --> WebMCP["web-search MCP\n(:8002)"] +``` **Key concepts:** -- OIDC / OAuth 2.0 fundamentals for M2M communication -- Auth0 configuration (free tier) -- JWT token flow: request, pass, validate -- FastAPI JWT validation middleware -- Separate Docker networks simulating different clusters -**Builds on:** Lesson 3 +- LangGraph checkpointer with PostgreSQL backend +- Thread-based conversation management (each project = a thread) +- Research result caching and incremental updates +- State persistence across agent restarts +- Docker Compose with PostgreSQL container + +**libs/common additions:** `agent_common.memory` -- checkpointer setup utilities + +**Builds on:** Pattern 02 --- -### Lesson 5: Agent Discovery in Enterprise Environments +### Pattern 04: Memory Lifecycle Management (Enrichment) -**Folder:** `examples/05-agent-discovery/` +**Folder:** `examples/04-memory-lifecycle/` -**Goal:** Implement and compare three agent discovery patterns for enterprise environments. +**Goal:** Manage growing agent memory with consolidation, expiration, and hierarchical organization. Introduce a Memory Refiner agent that runs periodically to keep the knowledge base accurate and compact. + +**What it solves:** After many research sessions, memory grows unbounded. Stale facts ("BTC price is $67k") pollute new analyses. The system needs to distinguish between ephemeral data (prices, news) and durable knowledge (project launch date, team composition). + +**Note:** This is an enrichment pattern. The main progression continues from Pattern 03 to Pattern 05. Skip this if your priority is distributed architecture. **Key concepts:** -- Explicit/static discovery (hardcoded URLs) -- Shared registry (central catalog service) -- A2A Agent Cards (`.well-known/agent-card.json` + registry scan) -- Enterprise trade-offs: governance, versioning, deprecation -- Building a simple agent registry service -**Builds on:** Lessons 3-4 +- Memory Refiner agent (consolidates and prunes the knowledge base) +- Fact TTL: timestamped facts with expiration policies + - Price data: 1-hour TTL + - News: 7-day TTL + - Project fundamentals: no expiration +- Hierarchical memory tiers: + - Working memory (current conversation context) + - Episodic memory (past research sessions) + - Semantic memory (consolidated, long-term knowledge) +- Memory compaction strategies + +**Builds on:** Pattern 03 --- -### Production (Lessons 6-8): Cloud + UI +## Distribution Tier (Patterns 05-07) -These lessons take the local Docker setup to production with Azure, add a UI, and combine everything. +Focus: splitting agents into separate services, introducing real distributed systems concerns. Each new team creates a genuine architectural challenge. --- -### Lesson 6: Azure Deployment and Infrastructure as Code +### Pattern 05: Distributed Agents -- A2A Protocol + +**Folder:** `examples/05-distributed-a2a/` + +**Goal:** Split agents across separate Docker containers -- simulating separate teams in an organization -- and communicate via the A2A (Agent-to-Agent) protocol. -**Folder:** `examples/06-azure-deployment/` +**What it solves:** In a real company, different teams build and deploy their agents independently. Team 1 (Intelligence) cannot import Team 2's code directly. They need a standardized protocol for task handoff: "Here's a crypto project name, give me a technical analysis." A2A provides this. -**Goal:** Deploy distributed agents to Azure using Infrastructure as Code (Bicep) with automated CI/CD. +**Story:** Team 2 (Technical Analysis) has built their own agent service with price data and indicator calculations. Team 1 needs to request technical analysis to enrich intelligence reports. The teams deploy independently and communicate via A2A. + +**Architecture:** + +```mermaid +graph TD + subgraph team1net ["Team 1: Intelligence Service (:8001)"] + RP["Research Planner"] + NS["News Scanner"] + PP["Project Profiler"] + CA["Community Analyst"] + IC["Intelligence Compiler"] + RP --> NS --> PP --> CA --> IC + end + subgraph team2net ["Team 2: Technical Analysis (:8002)"] + PC["Price Collector"] + IndCalc["Indicator Calculator"] + LA["Level Analyst"] + TR["Technical Reporter"] + PC --> IndCalc --> LA --> TR + end + IC -->|"A2A JSON-RPC\ntask/send"| TR + team1net -->|".well-known/agent-card.json"| Discovery + team2net -->|".well-known/agent-card.json"| Discovery["Capability\nAdvertisement"] +``` **Key concepts:** -- Infrastructure as Code (IaC) introduction with Azure Bicep -- Azure services: Container Registry, Container Apps, Key Vault, Log Analytics -- Bicep templates from scratch -- GitHub Actions deployment workflow -- Deploying the Lesson 3 distributed agents to Azure -**Builds on:** Lesson 5 +- A2A (Agent-to-Agent) protocol: JSON-RPC over HTTP +- Agent Cards (`.well-known/agent-card.json`) for capability advertisement +- Task lifecycle: `submitted` -> `working` -> `completed` +- Separate FastAPI services per team (independent deployment) +- Docker Compose with multiple services on the same network +- Protocol-driven endpoints replace REST API design + +**libs/common additions:** `agent_common.a2a` -- A2A protocol client/server helpers + +**Builds on:** Pattern 03 --- -### Lesson 7: Chat UI and Full Observability +### Pattern 06: Async Communication and Streaming -**Folder:** `examples/07-ui-and-observability/` +**Folder:** `examples/06-async-streaming/` -**Goal:** Build a simple chat UI and integrate comprehensive observability with LangSmith and OpenTelemetry. +**Goal:** Enable non-blocking agent communication and stream partial results as they become available. + +**What it solves:** Team 3 (Trading Signals) needs data from BOTH Team 1 and Team 2. Calling them sequentially takes 60+ seconds. Team 3 must fire parallel async requests and stream partial signals as data arrives. Synchronous A2A calls from Pattern 05 become a bottleneck. + +**Story:** Team 3 (Trading Signals) arrives. It fires parallel A2A requests to Team 1 and Team 2, merges results as they arrive, and streams buy/sell signals via SSE to the caller. + +**Architecture:** + +```mermaid +graph LR + subgraph team3 ["Team 3: Trading Signals (:8003)"] + SS["Signal Synthesizer"] + RA["Risk Assessor"] + TA["Trade Advisor"] + SS --> RA --> TA + end + T1["Team 1\nIntelligence\n(:8001)"] -->|"A2A async\npartial results"| SS + T2["Team 2\nTechnical\n(:8002)"] -->|"A2A async\npartial results"| SS + TA -->|"SSE stream"| Client["Client /\nClaude Code"] +``` **Key concepts:** -- Simple chat interface (text input, streamed responses) -- LangSmith dashboard: trace viewer, latency, error rates -- OpenTelemetry for infrastructure metrics -- Vercel deployment for the frontend -- Azure backend + Vercel frontend architecture -**Builds on:** Lesson 6 +- Async task submission (fire-and-poll vs. fire-and-wait) +- SSE (Server-Sent Events) for streaming partial results +- Parallel A2A requests: Team 3 calls Team 1 and Team 2 concurrently +- A2A async extensions (task status polling, push notifications) +- Non-blocking agent handoffs +- Backpressure and timeout patterns + +**Builds on:** Pattern 05 --- -### Lesson 8: Full Stack Integration +### Pattern 07: Cross-Network Authentication -**Folder:** `examples/08-full-stack-integration/` +**Folder:** `examples/07-cross-network-auth/` -**Goal:** Combine all patterns into a production-ready reference architecture. +**Goal:** Secure agent-to-agent communication when agents operate in different trust zones, using Auth0 as a shared OIDC provider. + +**What it solves:** Team 2 (Technical Analysis) is now operated by an external partner company. They run on a separate network with no implicit trust. Every A2A request must carry a JWT token. Without authentication, any service on the network could impersonate Team 1 and exfiltrate data from Team 2. + +**Story:** Team 2 moves to a partner organization. Teams 1 and 3 must authenticate every A2A call with JWT tokens issued by Auth0. Team 2 validates tokens before processing any task. + +**Architecture:** + +```mermaid +graph TD + subgraph internalNet ["Internal Network"] + T1["Team 1:\nIntelligence\n(:8001)"] + T3["Team 3:\nTrading Signals\n(:8003)"] + end + subgraph partnerNet ["Partner Network"] + T2["Team 2:\nTechnical Analysis\n(:8002)"] + end + Auth0["Auth0\n(OIDC Provider)"] + T1 -->|"1. Get M2M token"| Auth0 + T1 -->|"2. A2A + JWT"| T2 + T3 -->|"1. Get M2M token"| Auth0 + T3 -->|"2. A2A + JWT"| T2 + T2 -->|"Validate JWT"| Auth0 +``` **Key concepts:** -- End-to-end flow: UI -> orchestrator -> specialized agents -> auth -> results -- All protocols working together: MCP for tools, A2A for agent communication -- Auth0 for cross-service authentication -- Full Azure deployment with monitoring -- Production checklist and best practices -**Builds on:** All previous lessons +- Separate Docker networks simulating different organizational boundaries +- Auth0 OIDC / OAuth 2.0 for M2M (machine-to-machine) authentication +- JWT token flow: request -> attach to A2A call -> validate on receiver +- FastAPI JWT validation middleware +- Per-team client credentials +- Token caching and refresh patterns +- Zero-trust agent communication + +**libs/common additions:** `agent_common.auth` -- auth middleware and token client + +**Builds on:** Pattern 06 + +--- + +## Enterprise Tier (Patterns 08-09) + +Focus: production readiness -- discovery, observability, and cloud deployment. + +--- + +### Pattern 08: Agent Discovery and Observability + +**Folder:** `examples/08-discovery-observability/` + +**Goal:** Enable agents to find each other dynamically in enterprise environments, and monitor the full distributed system with distributed tracing. + +**What it solves:** With hardcoded URLs, adding a new agent capability requires code changes in every consumer. When Team 2 adds a "Whale Tracker" agent, Team 3 should discover and use it without redeployment. Meanwhile, with 12+ agents across 3 teams, debugging failures requires distributed tracing across A2A calls. + +**Story:** Team 2 adds a Whale Tracker agent that monitors large wallet movements. Team 3 discovers it dynamically through the shared agent registry and starts using it for trading signals -- no code changes, no redeployment. + +**Key concepts (Discovery):** + +- Three discovery patterns compared: + 1. Static/explicit (hardcoded URLs -- simplest, least flexible) + 2. Shared registry service (central catalog -- most common in enterprise) + 3. A2A Agent Cards with network scanning (decentralized -- most resilient) +- Registry service implementation (FastAPI + PostgreSQL) +- Agent registration, deregistration, health checking +- Capability-based agent matching +- Versioning and deprecation patterns + +**Key concepts (Observability):** + +- LangSmith dashboard: traces, latency, error rates across all 3 teams +- OpenTelemetry integration for infrastructure metrics +- Distributed tracing: correlate traces across A2A calls +- Health check patterns for agent liveness/readiness + +**Builds on:** Pattern 07 --- -## Phase 2: Conversational Tutoring System (Planned) +### Pattern 09: Cloud Deployment (Azure) + +**Folder:** `examples/09-cloud-deployment/` -See [phase2/README.md](phase2/README.md) for the detailed plan. Phase 2 applies all Phase 1 patterns to a real-world distributed system for conversational tutoring with multi-team agent collaboration. +**Goal:** Deploy the full three-team distributed system to Azure using Infrastructure as Code with automated CI/CD. + +**What it solves:** Docker Compose is great for local development, but production needs managed infrastructure: auto-scaling, secret management, centralized logging, health monitoring, and independent deployment pipelines per team. + +**Story:** All three teams go to production. Each deploys independently as an Azure Container App. Teams 1 and 3 are internal (Azure Managed Identity for auth), Team 2 is the external partner (Auth0 remains for cross-org calls). + +**Key concepts:** + +- Azure Container Apps for agent hosting (one per team) +- Azure Bicep templates for Infrastructure as Code +- Azure Container Registry for container images +- Azure Key Vault for secrets (replaces `.env`) +- Azure Managed Identity for internal auth (Team 1 <-> Team 3) +- Auth0 for cross-organization auth (Teams 1/3 <-> Team 2) +- GitHub Actions CI/CD pipeline (separate workflows per team) +- Log Analytics for centralized logging +- Cost optimization: scale-to-zero, consumption plans + +**Builds on:** Pattern 08 --- -## Deliverables Per Lesson +## Deliverables Per Pattern + +1. Self-contained working code (`docker compose up --build` to run) +2. Comprehensive `README.md` in the pattern folder +3. Full test suite (`tests/unit/`, `tests/api/`, `tests/e2e/`) +4. CHANGELOG entry + +--- -1. Working code example (`docker compose up` to run) -2. Lesson document in `docs/lessons/` (PDF-printable) -3. LinkedIn post draft -4. Blog post outline for ai.ksopyla.com -5. CHANGELOG entry +## Tech Stack + +- **Python 3.14+** with **uv** for package and Python version management +- **LangGraph** for agent orchestration (StateGraph with typed state) +- **FastAPI** for agent HTTP/protocol endpoints +- **Docker Compose** for local multi-container environments +- **LangSmith** for tracing and observability +- **MCP** for standardized tool access (Pattern 02+) +- **A2A** for agent-to-agent communication (Pattern 05+) +- **Auth0** for OIDC-based agent authentication (Pattern 07+) +- **Azure Container Apps** for cloud deployment (Pattern 09) diff --git a/docs/lessons/01-multi-agent-single-system.md b/docs/lessons/01-multi-agent-single-system.md deleted file mode 100644 index 2e06adb..0000000 --- a/docs/lessons/01-multi-agent-single-system.md +++ /dev/null @@ -1,184 +0,0 @@ -# Lesson 1: Multi-Agent System as a Single System - -## What You'll Learn - -- How to build a multi-agent system using LangGraph's StateGraph -- The orchestrator pattern: one graph coordinates multiple specialized agents -- How to integrate LangSmith tracing to observe every agent decision and tool call -- How to use verbose mode to debug and understand agent reasoning -- How to containerize and run agents with Docker Compose - -## The Problem - -You need to build a content research and writing pipeline. A single monolithic LLM prompt tries to do everything -- plan, research, and write -- but produces inconsistent results. The planning is shallow, the research is not focused, and the writing doesn't follow the plan. - -**The solution**: Split the work across three specialized agents that collaborate within a single system: - -1. **Planner** -- analyzes the request and creates a structured plan -2. **Researcher** -- follows the plan to gather relevant information -3. **Writer** -- uses the plan and research to produce the final output - -## Architecture - -```mermaid -graph TD - User["User Request\n(via FastAPI)"] --> Orchestrator - subgraph langGraph ["LangGraph StateGraph"] - Orchestrator["Entry Point"] --> Planner - Planner["Planner Agent\n(creates structured plan)"] --> Researcher - Researcher["Researcher Agent\n(gathers information)"] --> Writer - Writer["Writer Agent\n(produces final output)"] --> OutputNode["Output"] - end - OutputNode --> Response["JSON Response\n(with trace_id)"] - langGraph -.->|traces| LangSmith["LangSmith\n(trace viewer)"] -``` - -## Key Concepts - -### LangGraph StateGraph - -LangGraph models agent workflows as directed graphs where: -- **Nodes** are async functions that process and update shared state -- **Edges** define the flow between nodes (sequential, conditional, or parallel) -- **State** is a typed dictionary that flows through the graph - -```python -from typing import TypedDict -from langgraph.graph import StateGraph, END - -class AgentState(TypedDict): - messages: list[str] - plan: str - research: str - output: str - -graph = StateGraph(AgentState) -graph.add_node("planner", planner_node) -graph.add_node("researcher", researcher_node) -graph.add_node("writer", writer_node) - -graph.set_entry_point("planner") -graph.add_edge("planner", "researcher") -graph.add_edge("researcher", "writer") -graph.add_edge("writer", END) - -app = graph.compile() -``` - -### Why Multiple Agents Instead of One? - -| Aspect | Single Agent | Multi-Agent (This Lesson) | -|--------|-------------|--------------------------| -| Prompt complexity | Long, tries everything | Short, focused per role | -| Output quality | Inconsistent | Each step validates the previous | -| Debugging | Opaque "black box" | See each agent's reasoning | -| Extensibility | Hard to modify | Add/remove agents without breaking others | -| Cost | One expensive call | Multiple cheaper, focused calls | - -### LangSmith Integration - -LangSmith captures the full execution trace: -- Which agent ran, in what order -- What each agent received as input and produced as output -- How long each step took -- Token usage per agent - -Every trace gets a unique ID that you can use to look up the execution in the LangSmith dashboard. - -### Verbose Mode - -When `VERBOSE=true`, every agent logs to stderr: - -``` -[14:32:01.234] [Planner] Processing request: "Write about distributed AI agents" -[14:32:01.235] [Planner] └─ Creating structured plan... -[14:32:03.891] [Planner] Plan created with 3 sections -[14:32:03.892] [Researcher] Researching section 1: "Agent Communication Protocols" -[14:32:06.445] [Researcher] Found 5 relevant points -[14:32:06.446] [Writer] Writing final output based on plan and research -[14:32:09.123] [Writer] Output generated (847 words) -``` - -## Implementation Walkthrough - -### Step 1: Define the State - -The state is the data contract between all agents. Each agent reads what it needs and writes its output. - -```python -class AgentState(TypedDict): - input: str # Original user request - plan: str # Planner's output - research: str # Researcher's output - output: str # Writer's final output - messages: list[str] # Running log of agent activity -``` - -### Step 2: Build Agent Nodes - -Each agent is an async function that takes state, does work, and returns updates. - -```python -async def planner_node(state: AgentState) -> dict: - verbose_log("Planner", f"Planning for: {state['input'][:100]}") - llm = get_chat_model() - response = await llm.ainvoke([ - SystemMessage(content="You are a planning agent. Create a structured plan."), - HumanMessage(content=state["input"]), - ]) - return {"plan": response.content} -``` - -### Step 3: Wire Up the Graph - -Connect nodes with edges to define the execution flow. - -### Step 4: Expose via FastAPI - -Create a simple HTTP endpoint that invokes the graph and returns the result with a LangSmith trace ID. - -## Running the Example - -```bash -# From the repository root -cp .env.example .env -# Fill in AZURE_OPENAI_* or ANTHROPIC_API_KEY + LANGSMITH_API_KEY - -# Run with Docker -docker compose -f examples/01-multi-agent-single-system/docker-compose.yml up --build - -# Or with make -make example EX=01-multi-agent-single-system - -# Test the health endpoint -curl http://localhost:8000/health - -# Run the agent pipeline -curl -X POST http://localhost:8000/run \ - -H "Content-Type: application/json" \ - -d '{"input": "Write a brief analysis of AI agent communication protocols"}' -``` - -## What the Agents Are Doing (Debug Walkthrough) - -When you send a request, the verbose output shows the full chain: - -1. **FastAPI** receives the POST request and invokes the graph -2. **Planner** reads the input, calls the LLM to create a structured plan -3. **Researcher** reads the plan, calls the LLM to gather relevant information for each section -4. **Writer** reads both the plan and research, calls the LLM to produce the final output -5. **FastAPI** returns the output with the LangSmith trace ID - -Open the trace ID in LangSmith to see a visual timeline of each step, token usage, and latencies. - -## Exercises - -1. **Add a fourth agent**: Add a "reviewer" agent that reads the writer's output and suggests improvements. Wire it into the graph between writer and END. -2. **Conditional routing**: Modify the graph so that if the planner determines the request is simple (e.g., less than 2 sections), it skips the researcher and goes directly to the writer. -3. **Try both providers**: Run the example with Azure OpenAI, then switch to Anthropic (change `LLM_PROVIDER=anthropic` in `.env`). Compare the outputs and trace data. - -## Further Reading - -- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) -- [LangSmith Documentation](https://docs.smith.langchain.com/) -- [StateGraph API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/#langgraph.graph.StateGraph) diff --git a/docs/phase2/README.md b/docs/phase2/README.md deleted file mode 100644 index 9173e67..0000000 --- a/docs/phase2/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Phase 2: Conversational Tutoring System - -## Status: Planned (after Phase 1 completion) - -## Context - -Building a distributed multi-agent system for conversational tutoring (voice-to-voice) in a global corporation. The system generates tailored roleplay scenarios based on learner profiles and assesses conversations across multiple dimensions. - -This phase applies all patterns learned in Phase 1 to a real-world, cross-team architecture. - -## Architecture - -Two independent teams build and deploy their agent services separately, communicating via A2A protocol with Auth0 M2M authentication. - -### Team A: Scenario Generation - -| Agent | Responsibility | -|-------|---------------| -| Company Knowledge Collector | Gathers company-specific information, pain points, industry context | -| Learner Profile Builder/Updater | Builds and maintains learner profiles from conversation data | -| Scenario Generator | Creates roleplay scenarios tailored to company + learner context | -| Scenario Recommender | Selects the best scenario based on learning history and gaps | -| Feedback Orchestrator | After conversation, hands off to Team B for assessment via A2A | - -### Team B: Assessment - -| Agent | Responsibility | -|-------|---------------| -| Assessment Router | Receives handoff, routes to appropriate assessment agents | -| Finance Knowledge Checker | Evaluates domain-specific financial knowledge | -| English Correctness Feedback | Assesses language quality, grammar, vocabulary | -| Soft Skill Assessment | Evaluates communication style, empathy, negotiation | - -New assessment agents can be added to Team B without any changes to Team A. - -## Key Patterns from Phase 1 - -- **Lesson 3**: Distributed agents in separate containers (Team A vs Team B) -- **Lesson 4**: Auth0 M2M tokens for cross-team authentication -- **Lesson 5**: Agent discovery (Team B registers new assessment capabilities) -- **Lesson 2**: MCP for learner profile database access -- **Lesson 7**: Orchestrator pattern for scenario generation pipeline -- **Lesson 8**: Full stack integration with UI - -## Open Questions / Reminders - -- [ ] Define the exact learner profile schema (what data to extract from conversations) -- [ ] Decide on voice-to-voice integration (Azure Speech Services? Deepgram?) -- [ ] Design the assessment criteria format and how it maps to agent selection -- [ ] Plan the handoff protocol between Team A orchestrator and Team B router -- [ ] Consider: should Team B assessment results feed back into Team A's learner profile? -- [ ] Evaluate if a shared Supabase instance works or if each team needs their own data store -- [ ] Define SLA requirements for assessment turnaround time -- [ ] Plan for conversation transcript storage and privacy compliance diff --git a/docs/vision.md b/docs/vision.md new file mode 100644 index 0000000..4d0abd7 --- /dev/null +++ b/docs/vision.md @@ -0,0 +1,176 @@ +# Vision & Roadmap + +## Why This Exists + +The AI agent landscape in 2025-2026 is where microservices were in 2014: everyone talks about it, frameworks multiply weekly, but few teams have shipped production multi-agent systems. The gap isn't tooling -- LangGraph, CrewAI, AutoGen all work. The gap is **architectural knowledge**: how do you actually structure, deploy, and operate agents that work together? + +This project exists to close that gap. Not with slides or blog posts, but with **working code that progresses through real architectural challenges**, from a single Python process to a cloud-deployed distributed system. + +## The Software 3.0 Thesis + +Software engineering is undergoing a paradigm shift: + +**Software 1.0** -- Deterministic code. You write every instruction. Input → fixed logic → output. + +**Software 2.0** -- Machine learning models. You provide data, the model learns patterns. But the interface is still REST APIs, dashboards, and human-operated UIs. + +**Software 3.0** -- Autonomous agents. AI systems that reason, plan, use tools, and collaborate with other AI systems. The interface isn't a dashboard -- it's a protocol. Agents expose capabilities via MCP. Agents discover and invoke each other via A2A. The human interacts through an AI client (Claude Code, Cursor), not a bespoke chat widget or dedicated UI. + +This project demonstrates the transition. In Pattern 01, you build a familiar FastAPI endpoint -- `POST /run` -- that triggers a pipeline. Comfortable, recognizable, Software 2.0. By Pattern 05, that same FastAPI server hosts A2A JSON-RPC endpoints. By Pattern 09, agents discover each other dynamically, authenticate via JWT, and deploy as independent cloud services. HTTP is still the transport, but what travels over it has fundamentally changed. + +## Why "Design Patterns", Not "Tutorials" + +The name is intentional. "Tutorial" implies a one-time learning exercise you discard. "Design Pattern" implies a reusable architectural solution you reference repeatedly -- like the Gang of Four patterns, Cloud Design Patterns, or Microservices Patterns. + +Each pattern in this library: + +- **Solves a named architectural problem** ("How do agents in different trust zones authenticate?") +- **Has clear applicability criteria** ("Use when agents cross organizational boundaries") +- **Shows trade-offs** ("Simpler than mTLS, but requires a centralized OIDC provider") +- **Builds on previous patterns** (you understand the progression, not just isolated techniques) + +When a senior engineer asks "how should our agents communicate across services?", they can point to Pattern 05 (A2A) and Pattern 06 (async/streaming) as reference architectures -- not tutorials to follow, but patterns to adapt. + +## The Story: Building a Crypto Intelligence Platform + +Abstract architectural patterns are hard to internalize. Concrete stories stick. This project uses a single, evolving domain throughout all nine patterns: **a crypto project intelligence platform**. + +The domain was chosen deliberately: + +- **Rich data landscape** -- news, prices, social sentiment, on-chain data, GitHub activity. Plenty of distinct data sources for agents to specialize in. +- **Natural team boundaries** -- fundamentals research, technical analysis, and trading signals are genuinely different disciplines with different cadences, data sources, and expertise. +- **Real coordination needs** -- a trading signal is meaningless without both qualitative intelligence and quantitative analysis. Team 3 literally cannot function without Teams 1 and 2. +- **Free APIs available** -- CoinGecko, DuckDuckGo, GitHub all offer free tiers sufficient for the examples. +- **Compelling to developers** -- crypto is a domain where many developers have personal interest, making the examples more engaging than "process invoices" or "manage tickets." + +### Act 1: One Team, Growing Capabilities (Patterns 01-04) + +You are **Team 1: Intelligence**. Your job is fundamentals research -- given a crypto project name, produce a structured intelligence report covering news, technology, team, community, and roadmap. + +**Pattern 01** starts small. Three agents in a single LangGraph pipeline: a Research Planner breaks the task into subtasks, a News Scanner searches the web, and an Intelligence Compiler synthesizes everything into a report. It works. It's simple. It runs in one Docker container. + +But the News Scanner's web search is a hardcoded Python function call. What if you want Claude Code to use the same search capability? What if another team wants access to the same crypto data tools? + +**Pattern 02** introduces MCP. You build MCP servers for crypto data and web search. Now agents connect to tools via a standard protocol -- and so can Claude Code, Cursor, or any MCP-compatible client. The team expands to five agents: Project Profiler and Community Analyst join, each with their own MCP tool connections. The architecture moves from one container to multiple containers in Docker Compose. + +Now the team works well, but every request starts from scratch. You researched Arbitrum yesterday -- why are you re-scanning the same news today? + +**Pattern 03** adds persistent memory. LangGraph's checkpointer, backed by PostgreSQL, remembers previous research sessions. When you ask about Arbitrum again, the system provides incremental updates, not a full repeat. Each project becomes a "thread" with accumulated knowledge. + +Memory grows. After 50 research sessions, the knowledge base is bloated with stale price data, outdated news, and redundant facts. + +**Pattern 04** (optional enrichment) introduces memory lifecycle management. A Memory Refiner agent consolidates knowledge, expires stale facts (price data after 1 hour, news after 7 days), and organizes memory into tiers: working memory for the current conversation, episodic memory for past sessions, semantic memory for durable knowledge. + +At this point, Team 1 is a mature, memory-backed research engine. But it only knows about fundamentals. For investment decisions, you also need technical analysis -- price trends, indicators, support/resistance levels. + +### Act 2: A Second Team Arrives (Patterns 05-06) + +**Pattern 05** introduces **Team 2: Technical Analysis** -- a completely separate service with its own agents (Price Collector, Indicator Calculator, Level Analyst, Technical Reporter), its own Docker container, its own codebase. Team 1 can't `import` Team 2's code. They need a protocol. + +This is where A2A (Agent-to-Agent protocol) enters. Team 1's Intelligence Compiler submits a task to Team 2 via A2A JSON-RPC: "Give me technical analysis for Arbitrum." Team 2 processes it and returns a structured result. Each service publishes an Agent Card (`.well-known/agent-card.json`) advertising its capabilities. + +The architectural problem is real: how do separately deployed agent services exchange structured work? The answer is A2A -- and it mirrors how real engineering organizations operate, where teams build and deploy independently. + +**Pattern 06** brings **Team 3: Trading Signals**. This team needs data from *both* Team 1 (qualitative intelligence) and Team 2 (quantitative analysis) to produce buy/sell/hold recommendations. But calling them sequentially is unacceptable -- a full intelligence report takes 30+ seconds, technical analysis takes 20+ seconds. Sequential execution means 50+ seconds before Team 3 can even start. + +Team 3 must fire parallel async A2A requests, process partial results as they arrive, and stream trading signals to the caller via SSE. The synchronous A2A model from Pattern 05 becomes the bottleneck that motivates async communication and streaming. + +Three teams. Three services. Real distributed systems challenges. + +### Act 3: Enterprise Reality (Patterns 07-09) + +The three-team system works on a single Docker network, but real organizations don't have that luxury. + +**Pattern 07** simulates a real-world scenario: Team 2 (Technical Analysis) is now operated by an **external partner company**. They run on a separate network with no implicit trust. Every A2A request from Team 1 or Team 3 must carry a JWT token issued by Auth0 (OIDC provider). Team 2 validates the token before processing any task. + +Separate Docker networks simulate the organizational boundary. Auth0 M2M (machine-to-machine) credentials replace implicit trust. The lesson is zero-trust agent communication -- critical for any production deployment where agents cross organizational boundaries. + +**Pattern 08** addresses what happens as the system grows. Team 2 adds a new agent -- **Whale Tracker** -- that monitors large wallet movements. Team 3 wants to use it for better trading signals. But Team 3 doesn't know the agent exists. The URL isn't hardcoded anywhere. + +The solution is a shared agent registry: teams register their capabilities, consumers discover agents dynamically by querying "I need an agent that can track whale wallets." Team 3 discovers Whale Tracker without code changes, without redeployment. + +Alongside discovery, with 12+ agents across 3 teams, observability becomes critical. Distributed tracing across A2A calls (using OpenTelemetry and LangSmith) lets you follow a single trading signal request as it flows through Team 3 → Team 1 + Team 2 → back to Team 3. + +**Pattern 09** takes everything to production on Azure. Each team deploys as an independent Azure Container App. Infrastructure is defined in Bicep templates. Secrets move from `.env` files to Azure Key Vault. Internal auth (Team 1 ↔ Team 3) uses Azure Managed Identity. Cross-org auth (Teams 1/3 ↔ Team 2) keeps Auth0. GitHub Actions provides CI/CD with separate pipelines per team. + +The journey is complete: from a single Python file to a cloud-deployed, authenticated, observable, dynamically-discoverable multi-agent system. + +## Why Each Pattern Matters + +Every pattern exists because the previous one creates a real limitation: + +| Transition | The problem that forces the next pattern | +|------------|------------------------------------------| +| P01 → P02 | Hardcoded tools don't scale. You can't share tools with Claude Code or other teams. | +| P02 → P03 | Every request starts fresh. Repeated research wastes tokens and time. | +| P03 → P04 | Memory grows unbounded. Stale facts pollute analysis. | +| P03 → P05 | A second team arrives. You can't import their code. You need a protocol. | +| P05 → P06 | A third team needs data from both others. Sequential calls are too slow. | +| P06 → P07 | Team 2 moves to a partner org. Implicit trust is gone. | +| P07 → P08 | New agents appear. Consumers shouldn't need code changes to use them. | +| P08 → P09 | Docker Compose doesn't work in production. You need cloud infrastructure. | + +No pattern is introduced "because it's next on the list." Each one is motivated by a genuine architectural limitation that the reader has experienced firsthand by building the previous pattern. + +## Key Architectural Decisions + +### FastAPI: The Constant That Evolves + +FastAPI is the HTTP server runtime in every pattern, but what it serves changes fundamentally: + +- **Pattern 01**: `POST /run` -- a REST trigger endpoint. Familiar, comfortable. +- **Pattern 02**: MCP server endpoints. The contract shifts from REST resources to protocol operations. +- **Pattern 05+**: A2A JSON-RPC endpoints (`/a2a`). No more REST-style routes -- everything is protocol-driven. + +HTTP remains the transport. But by Pattern 05, it's carrying A2A protocol messages, not REST API calls. This progression itself demonstrates the Software 2.0 → 3.0 transition. + +### No Custom UI + +Building a chat interface is Software 2.0 thinking. In Software 3.0: + +- Agents expose themselves as **MCP servers** -- any MCP-compatible client (Claude Code, Claude Desktop, Cursor) can connect and interact. +- Agents expose **A2A endpoints** -- other AI agents can discover and invoke them. +- For development and testing: Claude Code connects to your Dockerized agents via MCP config pointing to `localhost`. + +The "UI" is Claude Code. This is introduced in Pattern 02 and reinforced throughout. + +### Three Teams, Not Two + +Two teams would demonstrate A2A, but three teams create richer architectural challenges: + +- **Parallel requests**: Team 3 must call Teams 1 and 2 concurrently (motivates async in P06) +- **Asymmetric trust**: Teams 1 and 3 are internal, Team 2 is external (motivates per-network auth in P07) +- **Dynamic discovery**: Team 3 discovers new capabilities from Team 2 without redeployment (motivates registry in P08) +- **Independent deployment**: each team has its own CI/CD pipeline in cloud (motivates IaC in P09) + +Two teams would leave half these problems unaddressed. + +## What This Project Is Not + +- **Not a framework.** It doesn't produce a reusable library you pip-install. It produces reference implementations you study and adapt. +- **Not a product.** The crypto intelligence platform is a vehicle for teaching architecture, not a production trading system. +- **Not an LLM comparison.** The patterns work with any LLM (Azure OpenAI, Anthropic, etc.). The focus is architecture, not model selection. +- **Not a beginner tutorial.** The reader should be comfortable with Python, Docker, and async programming. The patterns teach distributed agent architecture, not programming fundamentals. + +## Roadmap + +### Current: Foundation Tier + +Building Patterns 01-04 -- the single-team foundation with orchestration, MCP tools, and persistent memory. + +### Next: Distribution Tier + +Patterns 05-07 -- splitting into multiple services with A2A, async communication, and cross-network authentication. + +### Future: Enterprise Tier + +Patterns 08-09 -- agent discovery, observability, and Azure cloud deployment. + +### Beyond Pattern 09 + +Ideas for potential future patterns (not yet planned): + +- **Multi-model orchestration** -- different LLMs for different agents based on cost/capability trade-offs +- **Human-in-the-loop** -- approval workflows for high-stakes trading signals +- **Agent testing patterns** -- property-based testing, simulation environments, adversarial testing +- **Multi-cloud deployment** -- AWS or GCP alternatives to Pattern 09 diff --git a/examples/01-multi-agent-single-system/src/agents/graph.py b/examples/01-multi-agent-single-system/src/agents/graph.py deleted file mode 100644 index b1cafa2..0000000 --- a/examples/01-multi-agent-single-system/src/agents/graph.py +++ /dev/null @@ -1,34 +0,0 @@ -"""LangGraph StateGraph wiring: planner -> researcher -> writer.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -from agent_common.tracing import verbose_log -from langgraph.graph import END, StateGraph - -from src.agents.planner import planner_node -from src.agents.researcher import researcher_node -from src.agents.state import AgentState -from src.agents.writer import writer_node - -if TYPE_CHECKING: - from langgraph.graph.state import CompiledStateGraph - - -def build_graph() -> CompiledStateGraph: - """Build and compile the multi-agent pipeline graph.""" - verbose_log("System", "Building agent graph: planner -> researcher -> writer") - - graph = StateGraph(AgentState) - - graph.add_node("planner", planner_node) - graph.add_node("researcher", researcher_node) - graph.add_node("writer", writer_node) - - graph.set_entry_point("planner") - graph.add_edge("planner", "researcher") - graph.add_edge("researcher", "writer") - graph.add_edge("writer", END) - - return graph.compile() diff --git a/examples/01-multi-agent-single-system/src/agents/planner.py b/examples/01-multi-agent-single-system/src/agents/planner.py deleted file mode 100644 index e6305b5..0000000 --- a/examples/01-multi-agent-single-system/src/agents/planner.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Planner agent -- analyzes the request and creates a structured plan.""" - -from __future__ import annotations - -from agent_common.llm import get_chat_model -from agent_common.tracing import verbose_log -from langchain_core.messages import HumanMessage, SystemMessage - -from src.agents.state import AgentState - -SYSTEM_PROMPT = """You are a planning agent. Your job is to analyze the user's request and create -a clear, structured plan for researching and writing about the topic. - -Output a numbered list of 2-4 sections that should be covered, with a brief description of what -each section should contain. Keep it concise and actionable.""" - - -async def planner_node(state: AgentState) -> dict[str, str]: - """Create a structured plan based on the user's input.""" - user_input = state["input"] - verbose_log("Planner", f"Planning for: {user_input[:100]}") - - llm = get_chat_model() - response = await llm.ainvoke( - [ - SystemMessage(content=SYSTEM_PROMPT), - HumanMessage(content=user_input), - ] - ) - - plan = str(response.content) - verbose_log("Planner", f"Plan created ({len(plan)} chars)") - verbose_log("Planner", "Plan content", plan[:200]) - - return {"plan": plan} diff --git a/examples/01-multi-agent-single-system/src/agents/researcher.py b/examples/01-multi-agent-single-system/src/agents/researcher.py deleted file mode 100644 index f86d00b..0000000 --- a/examples/01-multi-agent-single-system/src/agents/researcher.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Researcher agent -- gathers information based on the plan.""" - -from __future__ import annotations - -from agent_common.llm import get_chat_model -from agent_common.tracing import verbose_log -from langchain_core.messages import HumanMessage, SystemMessage - -from src.agents.state import AgentState - -SYSTEM_PROMPT = """You are a research agent. You receive a plan with sections to cover. -For each section in the plan, provide 2-3 key facts, insights, or points that would be -useful for writing about that topic. - -Be concise but informative. Focus on practical, actionable information.""" - - -async def researcher_node(state: AgentState) -> dict[str, str]: - """Research information for each section in the plan.""" - plan = state["plan"] - verbose_log("Researcher", "Researching based on plan") - - llm = get_chat_model() - response = await llm.ainvoke( - [ - SystemMessage(content=SYSTEM_PROMPT), - HumanMessage(content=f"Original request: {state['input']}\n\nPlan:\n{plan}"), - ] - ) - - research = str(response.content) - verbose_log("Researcher", f"Research complete ({len(research)} chars)") - - return {"research": research} \ No newline at end of file diff --git a/examples/01-multi-agent-single-system/src/agents/writer.py b/examples/01-multi-agent-single-system/src/agents/writer.py deleted file mode 100644 index 5e507c8..0000000 --- a/examples/01-multi-agent-single-system/src/agents/writer.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Writer agent -- produces the final output using plan and research.""" - -from __future__ import annotations - -from agent_common.llm import get_chat_model -from agent_common.tracing import verbose_log -from langchain_core.messages import HumanMessage, SystemMessage - -from src.agents.state import AgentState - -SYSTEM_PROMPT = """You are a writing agent. You receive a plan and research findings. -Write a clear, well-structured piece that follows the plan and incorporates the research. - -Keep the writing concise, professional, and informative. Use headers for each section. -Target 300-500 words.""" - - -async def writer_node(state: AgentState) -> dict[str, str]: - """Write the final output based on plan and research.""" - plan = state.get("plan", "") - research = state.get("research", "") - verbose_log("Writer", "Writing final output") - - llm = get_chat_model() - response = await llm.ainvoke( - [ - SystemMessage(content=SYSTEM_PROMPT), - HumanMessage( - content=f"Original request: {state['input']}\n\nPlan:\n{plan}\n\nResearch:\n{research}" - ), - ] - ) - - output = str(response.content) - verbose_log("Writer", f"Output generated ({len(output)} chars)") - - return {"output": output} diff --git a/examples/01-multi-agent-single-system/tests/test_agents.py b/examples/01-multi-agent-single-system/tests/test_agents.py deleted file mode 100644 index fefcf9b..0000000 --- a/examples/01-multi-agent-single-system/tests/test_agents.py +++ /dev/null @@ -1,20 +0,0 @@ -"""Tests for the multi-agent pipeline.""" - -from __future__ import annotations - -from src.agents.state import AgentState - - -def test_agent_state_accepts_required_fields() -> None: - state: AgentState = {"input": "test request"} - assert state["input"] == "test request" - - -def test_agent_state_accepts_all_fields() -> None: - state: AgentState = { - "input": "test", - "plan": "plan text", - "research": "research text", - "output": "output text", - } - assert state["output"] == "output text" diff --git a/examples/01-orchestrator-pipeline/README.md b/examples/01-orchestrator-pipeline/README.md new file mode 100644 index 0000000..870cf6c --- /dev/null +++ b/examples/01-orchestrator-pipeline/README.md @@ -0,0 +1,192 @@ +# Pattern 01: Orchestrator Pipeline + +> Decompose a complex research task across multiple specialized agents using LangGraph StateGraph. + +## What You'll Learn + +- How to build a multi-agent system using LangGraph's StateGraph +- The orchestrator pattern: one graph coordinates multiple specialized agents +- How to integrate external tools (web search) into agent nodes +- How to use LangSmith tracing to observe every agent decision +- How to containerize and run agents with Docker Compose + +## The Problem + +You need to build a crypto project intelligence system. A single monolithic LLM prompt tries to plan, research, and write all at once -- the planning is shallow, the research unfocused, and the report inconsistent. + +**The solution**: Split responsibility across three focused agents that collaborate within a single LangGraph StateGraph: + +1. **Research Planner** -- analyzes the request and creates a structured research plan +2. **News Scanner** -- searches the web for relevant information using DuckDuckGo +3. **Intelligence Compiler** -- synthesizes findings into a structured intelligence report + +## Architecture + +```mermaid +graph TD + User["User Request\n(POST /run)"] --> FastAPI + FastAPI --> StateGraph + subgraph StateGraph ["LangGraph StateGraph"] + Planner["Research Planner\n(creates research plan)"] + Scanner["News Scanner\n(DuckDuckGo + analysis)"] + Compiler["Intelligence Compiler\n(structured report)"] + Planner --> Scanner + Scanner --> Compiler + end + Compiler --> Response["Intelligence Report\n(JSON)"] + StateGraph -.->|traces| LangSmith +``` + +## Key Concepts + +### LangGraph StateGraph + +LangGraph models agent workflows as directed graphs: + +- **Nodes** are async functions that process and update shared state +- **Edges** define the flow between nodes (sequential, conditional, or parallel) +- **State** is a typed dictionary that flows through the graph + +```python +class AgentState(TypedDict, total=False): + input: Required[str] + plan: str + news: str + report: str +``` + +### Why Multiple Agents Instead of One? + +| Aspect | Single Agent | Multi-Agent (This Pattern) | +|--------|-------------|---------------------------| +| Prompt complexity | Long, tries everything | Short, focused per role | +| Output quality | Inconsistent | Each step validates the previous | +| Debugging | Opaque "black box" | See each agent's reasoning in LangSmith | +| Extensibility | Hard to modify | Add/remove agents without breaking others | +| Cost | One expensive call | Multiple cheaper, focused calls | + +### LangSmith Integration + +LangSmith captures the full execution trace -- which agent ran, inputs/outputs, timing, and token usage. Every trace gets a unique ID for dashboard lookup. + +### Verbose Mode + +When `VERBOSE=true`, every agent logs to stderr with timestamps: + +``` +[14:32:01.234] [ResearchPlanner] Planning research for: Research Arbitrum +[14:32:03.891] [ResearchPlanner] Plan created (245 chars) +[14:32:03.892] [NewsScanner] Searching for: Research Arbitrum +[14:32:06.445] [NewsScanner] Got 8 search results +[14:32:08.901] [NewsScanner] Analysis complete (523 chars) +[14:32:08.902] [IntelligenceCompiler] Compiling intelligence report +[14:32:11.678] [IntelligenceCompiler] Report generated (847 chars) +``` + +## Implementation + +### Step 1: Define the State + +The state is the data contract between all agents. Each agent reads what it needs and writes its output. + +```python +class AgentState(TypedDict, total=False): + input: Required[str] # Crypto project query + plan: str # Research Planner's output + news: str # News Scanner's analyzed findings + report: str # Intelligence Compiler's final report +``` + +### Step 2: Build Agent Nodes + +Each agent is an async function that takes state, does work, and returns updates. + +```python +async def research_planner_node(state: AgentState) -> dict[str, str]: + llm = get_chat_model() + response = await llm.ainvoke([ + SystemMessage(content=SYSTEM_PROMPT), + HumanMessage(content=state["input"]), + ]) + return {"plan": str(response.content)} +``` + +The News Scanner adds a tool call -- DuckDuckGo web search: + +```python +async def news_scanner_node(state: AgentState) -> dict[str, str]: + search = DuckDuckGoSearchResults(max_results=8, output_format="list") + raw_results = await search.ainvoke(f"{state['input']} crypto project news 2026") + + llm = get_chat_model() + response = await llm.ainvoke([ + SystemMessage(content=SYSTEM_PROMPT), + HumanMessage(content=f"Query: {state['input']}\nPlan: {state['plan']}\nResults: {raw_results}"), + ]) + return {"news": str(response.content)} +``` + +### Step 3: Wire the Graph + +```python +graph = StateGraph(AgentState) +graph.add_node("research_planner", research_planner_node) +graph.add_node("news_scanner", news_scanner_node) +graph.add_node("intelligence_compiler", intelligence_compiler_node) + +graph.set_entry_point("research_planner") +graph.add_edge("research_planner", "news_scanner") +graph.add_edge("news_scanner", "intelligence_compiler") +graph.add_edge("intelligence_compiler", END) + +app = graph.compile() +``` + +### Step 4: Expose via FastAPI + +A simple HTTP endpoint invokes the graph and returns the structured report. + +## Running the Example + +```bash +# From the repository root +cp .env.example .env +# Fill in AZURE_OPENAI_* or ANTHROPIC_API_KEY + LANGSMITH_API_KEY + +# Run with Docker +docker compose -f examples/01-orchestrator-pipeline/docker-compose.yml up --build + +# Or with make +make example EX=01-orchestrator-pipeline + +# Test the health endpoint +curl http://localhost:8000/health + +# Run the intelligence pipeline +curl -X POST http://localhost:8000/run \ + -H "Content-Type: application/json" \ + -d '{"input": "Research the Arbitrum crypto project"}' +``` + +## Exercises + +1. **Add a fourth agent**: Add a "Fact Checker" agent between the News Scanner and Intelligence Compiler that validates claims found in the research. +2. **Conditional routing**: Modify the graph so that if the Research Planner determines the request is about a well-known project (BTC, ETH), it uses a shorter plan. +3. **Parallel research**: Split the News Scanner into two parallel nodes -- one for news and one for project/team info -- using LangGraph's fan-out pattern. + +## Trade-offs + +| Advantage | Limitation | +|-----------|-----------| +| Clear separation of concerns | Sequential execution adds latency | +| Easy to debug with LangSmith traces | All agents share a single process | +| Each agent's prompt is short and focused | No persistence -- every request starts fresh | +| Tool use is straightforward | Tools are hardcoded, not standardized | + +**Next pattern** (Pattern 02) addresses the last two limitations by adding MCP for standardized tool access and introducing additional agents. + +## Further Reading + +- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/) +- [LangSmith Documentation](https://docs.smith.langchain.com/) +- [StateGraph API Reference](https://langchain-ai.github.io/langgraph/reference/graphs/) diff --git a/examples/01-multi-agent-single-system/docker-compose.yml b/examples/01-orchestrator-pipeline/docker-compose.yml similarity index 87% rename from examples/01-multi-agent-single-system/docker-compose.yml rename to examples/01-orchestrator-pipeline/docker-compose.yml index 47b45f2..aed2a86 100644 --- a/examples/01-multi-agent-single-system/docker-compose.yml +++ b/examples/01-orchestrator-pipeline/docker-compose.yml @@ -4,7 +4,7 @@ services: context: ../.. dockerfile: infra/docker/base/Dockerfile.agent args: - EXAMPLE_DIR: examples/01-multi-agent-single-system + EXAMPLE_DIR: examples/01-orchestrator-pipeline ports: - "8000:8000" env_file: diff --git a/examples/01-multi-agent-single-system/pyproject.toml b/examples/01-orchestrator-pipeline/pyproject.toml similarity index 64% rename from examples/01-multi-agent-single-system/pyproject.toml rename to examples/01-orchestrator-pipeline/pyproject.toml index 0d8acf2..351afd3 100644 --- a/examples/01-multi-agent-single-system/pyproject.toml +++ b/examples/01-orchestrator-pipeline/pyproject.toml @@ -1,14 +1,16 @@ [project] -name = "example-01-multi-agent-single-system" +name = "example-01-orchestrator-pipeline" version = "0.1.0" -description = "Lesson 1: Multi-Agent System as a Single System with LangGraph" +description = "Pattern 01: Orchestrator Pipeline -- multi-agent crypto intelligence with LangGraph" requires-python = ">=3.14" dependencies = [ "langgraph>=0.4", "langchain-core>=0.3", + "langchain-community>=0.3", "langchain-openai>=0.3", "langchain-anthropic>=0.3", "langsmith>=0.3", + "duckduckgo-search>=8.0", "fastapi>=0.115", "uvicorn>=0.34", "pydantic>=2.0", diff --git a/examples/01-multi-agent-single-system/src/__init__.py b/examples/01-orchestrator-pipeline/src/__init__.py similarity index 100% rename from examples/01-multi-agent-single-system/src/__init__.py rename to examples/01-orchestrator-pipeline/src/__init__.py diff --git a/examples/01-orchestrator-pipeline/src/agents/__init__.py b/examples/01-orchestrator-pipeline/src/agents/__init__.py new file mode 100644 index 0000000..4b7f9a5 --- /dev/null +++ b/examples/01-orchestrator-pipeline/src/agents/__init__.py @@ -0,0 +1,5 @@ +"""Crypto intelligence pipeline: research_planner, news_scanner, intelligence_compiler.""" + +from src.agents.graph import build_graph + +__all__ = ["build_graph"] diff --git a/examples/01-orchestrator-pipeline/src/agents/graph.py b/examples/01-orchestrator-pipeline/src/agents/graph.py new file mode 100644 index 0000000..bb555c6 --- /dev/null +++ b/examples/01-orchestrator-pipeline/src/agents/graph.py @@ -0,0 +1,34 @@ +"""LangGraph StateGraph wiring: research_planner -> news_scanner -> intelligence_compiler.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from agent_common.tracing import verbose_log +from langgraph.graph import END, StateGraph + +from src.agents.intelligence_compiler import intelligence_compiler_node +from src.agents.news_scanner import news_scanner_node +from src.agents.research_planner import research_planner_node +from src.agents.state import AgentState + +if TYPE_CHECKING: + from langgraph.graph.state import CompiledStateGraph + + +def build_graph() -> CompiledStateGraph: # type: ignore[type-arg] + """Build and compile the crypto intelligence pipeline graph.""" + verbose_log("System", "Building agent graph: research_planner -> news_scanner -> intelligence_compiler") + + graph = StateGraph(AgentState) + + graph.add_node("research_planner", research_planner_node) + graph.add_node("news_scanner", news_scanner_node) + graph.add_node("intelligence_compiler", intelligence_compiler_node) + + graph.set_entry_point("research_planner") + graph.add_edge("research_planner", "news_scanner") + graph.add_edge("news_scanner", "intelligence_compiler") + graph.add_edge("intelligence_compiler", END) + + return graph.compile() diff --git a/examples/01-orchestrator-pipeline/src/agents/intelligence_compiler.py b/examples/01-orchestrator-pipeline/src/agents/intelligence_compiler.py new file mode 100644 index 0000000..2627bb6 --- /dev/null +++ b/examples/01-orchestrator-pipeline/src/agents/intelligence_compiler.py @@ -0,0 +1,45 @@ +"""Intelligence Compiler agent -- synthesizes research into a structured report.""" + +from __future__ import annotations + +from agent_common.llm import get_chat_model +from agent_common.tracing import verbose_log +from langchain_core.messages import HumanMessage, SystemMessage + +from src.agents.state import AgentState + +SYSTEM_PROMPT = """\ +You are a crypto intelligence analyst. You receive a research plan and analyzed news +findings about a crypto project. + +Produce a structured intelligence report with the following sections: +1. **Executive Summary** -- 2-3 sentence overview of the project's current state +2. **Key Findings** -- Bullet points of the most important discoveries +3. **Recent Developments** -- Notable news, partnerships, or milestones +4. **Risk Factors** -- Potential concerns or red flags +5. **Outlook** -- Brief forward-looking assessment + +Keep the report factual, professional, and concise (300-500 words). +Clearly distinguish between verified facts and speculation.""" + + +async def intelligence_compiler_node(state: AgentState) -> dict[str, str]: + """Compile research findings into a structured intelligence report.""" + plan = state.get("plan", "") + news = state.get("news", "") + verbose_log("IntelligenceCompiler", "Compiling intelligence report") + + llm = get_chat_model() + response = await llm.ainvoke( + [ + SystemMessage(content=SYSTEM_PROMPT), + HumanMessage( + content=(f"Crypto project: {state['input']}\n\nResearch plan:\n{plan}\n\nAnalyzed findings:\n{news}") + ), + ] + ) + + report = str(response.content) + verbose_log("IntelligenceCompiler", f"Report generated ({len(report)} chars)") + + return {"report": report} diff --git a/examples/01-orchestrator-pipeline/src/agents/news_scanner.py b/examples/01-orchestrator-pipeline/src/agents/news_scanner.py new file mode 100644 index 0000000..86c6b6b --- /dev/null +++ b/examples/01-orchestrator-pipeline/src/agents/news_scanner.py @@ -0,0 +1,56 @@ +"""News Scanner agent -- searches the web and analyzes findings about a crypto project.""" + +from __future__ import annotations + +from agent_common.llm import get_chat_model +from agent_common.tracing import verbose_log +from langchain_community.tools import DuckDuckGoSearchResults +from langchain_core.messages import HumanMessage, SystemMessage + +from src.agents.state import AgentState + +SYSTEM_PROMPT = """\ +You are a crypto news analyst. You receive a research plan and raw web search results +about a crypto project. + +Analyze the search results and extract the most relevant information organized by +the research plan's areas. For each area, provide: +- Key facts and data points found +- Notable quotes or claims +- How recent and reliable the information appears + +If search results don't cover an area, note it as "No data found." +Be factual and cite sources where possible.""" + + +async def news_scanner_node(state: AgentState) -> dict[str, str]: + """Search the web for crypto project information and analyze results.""" + user_input = state["input"] + plan = state.get("plan", "") + verbose_log("NewsScanner", f"Searching for: {user_input[:80]}") + + search = DuckDuckGoSearchResults( + max_results=8, # type: ignore[call-arg] + output_format="list", + ) + raw_results = await search.ainvoke(f"{user_input} crypto project news 2026") + verbose_log("NewsScanner", f"Got {len(raw_results) if isinstance(raw_results, list) else '?'} search results") + + llm = get_chat_model() + response = await llm.ainvoke( + [ + SystemMessage(content=SYSTEM_PROMPT), + HumanMessage( + content=( + f"Crypto project query: {user_input}\n\n" + f"Research plan:\n{plan}\n\n" + f"Web search results:\n{raw_results}" + ) + ), + ] + ) + + news = str(response.content) + verbose_log("NewsScanner", f"Analysis complete ({len(news)} chars)") + + return {"news": news} diff --git a/examples/01-orchestrator-pipeline/src/agents/research_planner.py b/examples/01-orchestrator-pipeline/src/agents/research_planner.py new file mode 100644 index 0000000..5a29658 --- /dev/null +++ b/examples/01-orchestrator-pipeline/src/agents/research_planner.py @@ -0,0 +1,42 @@ +"""Research Planner agent -- analyzes the crypto project request and creates a research plan.""" + +from __future__ import annotations + +from agent_common.llm import get_chat_model +from agent_common.tracing import verbose_log +from langchain_core.messages import HumanMessage, SystemMessage + +from src.agents.state import AgentState + +SYSTEM_PROMPT = """\ +You are a crypto project research planner. Given a crypto project name or topic, +create a focused research plan. + +Output a numbered list of 3-5 specific research areas to investigate. For each area, +write one sentence describing what to look for. Focus on: +- Recent news, announcements, and partnerships +- Project goals, technology, and roadmap milestones +- Team background and recent updates +- Community sentiment and adoption signals +- Notable events or controversies + +Keep it concise and actionable.""" + + +async def research_planner_node(state: AgentState) -> dict[str, str]: + """Create a structured research plan for the crypto project.""" + user_input = state["input"] + verbose_log("ResearchPlanner", f"Planning research for: {user_input[:100]}") + + llm = get_chat_model() + response = await llm.ainvoke( + [ + SystemMessage(content=SYSTEM_PROMPT), + HumanMessage(content=user_input), + ] + ) + + plan = str(response.content) + verbose_log("ResearchPlanner", f"Plan created ({len(plan)} chars)") + + return {"plan": plan} diff --git a/examples/01-multi-agent-single-system/src/agents/state.py b/examples/01-orchestrator-pipeline/src/agents/state.py similarity index 59% rename from examples/01-multi-agent-single-system/src/agents/state.py rename to examples/01-orchestrator-pipeline/src/agents/state.py index 77f57ab..2469969 100644 --- a/examples/01-multi-agent-single-system/src/agents/state.py +++ b/examples/01-orchestrator-pipeline/src/agents/state.py @@ -1,4 +1,4 @@ -"""Typed state shared across all agent nodes.""" +"""Typed state shared across all agent nodes in the intelligence pipeline.""" from __future__ import annotations @@ -8,5 +8,5 @@ class AgentState(TypedDict, total=False): input: Required[str] plan: str - research: str - output: str + news: str + report: str diff --git a/examples/01-multi-agent-single-system/src/app.py b/examples/01-orchestrator-pipeline/src/app.py similarity index 77% rename from examples/01-multi-agent-single-system/src/app.py rename to examples/01-orchestrator-pipeline/src/app.py index 0890b1a..cb4251c 100644 --- a/examples/01-multi-agent-single-system/src/app.py +++ b/examples/01-orchestrator-pipeline/src/app.py @@ -1,4 +1,4 @@ -"""FastAPI application exposing the multi-agent pipeline.""" +"""FastAPI application exposing the crypto intelligence pipeline.""" from __future__ import annotations @@ -21,8 +21,8 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: app = FastAPI( - title="Lesson 1: Multi-Agent Single System", - description="Three LangGraph agents (planner, researcher, writer) collaborating in a single pipeline.", + title="Pattern 01: Orchestrator Pipeline", + description="Three-agent crypto intelligence pipeline (Research Planner, News Scanner, Intelligence Compiler).", version="0.1.0", lifespan=lifespan, ) @@ -33,9 +33,9 @@ class RunRequest(BaseModel): class RunResponse(BaseModel): - output: str + report: str plan: str - research: str + news: str @app.get("/health") @@ -53,7 +53,7 @@ async def run(request: RunRequest) -> RunResponse: verbose_log("System", "Pipeline complete, returning response") return RunResponse( - output=result.get("output", ""), + report=result.get("report", ""), plan=result.get("plan", ""), - research=result.get("research", ""), + news=result.get("news", ""), ) diff --git a/examples/01-orchestrator-pipeline/tests/api/test_app_api.py b/examples/01-orchestrator-pipeline/tests/api/test_app_api.py new file mode 100644 index 0000000..ab299f5 --- /dev/null +++ b/examples/01-orchestrator-pipeline/tests/api/test_app_api.py @@ -0,0 +1,56 @@ +"""API tests for FastAPI endpoints.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +import pytest +from fastapi.testclient import TestClient +from src import app as app_module + + +@dataclass +class _FakeGraph: + result: dict[str, Any] + received_input: dict[str, str] | None = None + + async def ainvoke(self, payload: dict[str, str]) -> dict[str, Any]: + self.received_input = payload + return self.result + + +def test_health_endpoint_returns_ok() -> None: + with TestClient(app_module.app) as client: + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_run_endpoint_executes_graph(monkeypatch: pytest.MonkeyPatch) -> None: + fake_graph = _FakeGraph( + result={ + "report": "## Executive Summary\nArbitrum analysis.", + "plan": "1. News\n2. Team", + "news": "Partnership announced.", + } + ) + monkeypatch.setattr(app_module, "build_graph", lambda: fake_graph) + + with TestClient(app_module.app) as client: + response = client.post("/run", json={"input": "Research Arbitrum"}) + + assert response.status_code == 200 + data = response.json() + assert data["report"] == "## Executive Summary\nArbitrum analysis." + assert data["plan"] == "1. News\n2. Team" + assert data["news"] == "Partnership announced." + assert fake_graph.received_input == {"input": "Research Arbitrum"} + + +def test_run_endpoint_validates_request() -> None: + with TestClient(app_module.app) as client: + response = client.post("/run", json={}) + + assert response.status_code == 422 diff --git a/examples/01-orchestrator-pipeline/tests/conftest.py b/examples/01-orchestrator-pipeline/tests/conftest.py new file mode 100644 index 0000000..a02acfe --- /dev/null +++ b/examples/01-orchestrator-pipeline/tests/conftest.py @@ -0,0 +1,18 @@ +"""Pytest configuration for example 01 tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] + +for key in list(sys.modules.keys()): + if key == "src" or key.startswith("src."): + mod = sys.modules[key] + mod_file = getattr(mod, "__file__", None) or "" + if mod_file and str(EXAMPLE_ROOT) not in mod_file: + del sys.modules[key] + +if str(EXAMPLE_ROOT) not in sys.path: + sys.path.insert(0, str(EXAMPLE_ROOT)) diff --git a/examples/01-orchestrator-pipeline/tests/e2e/test_pipeline_graph.py b/examples/01-orchestrator-pipeline/tests/e2e/test_pipeline_graph.py new file mode 100644 index 0000000..66a8baa --- /dev/null +++ b/examples/01-orchestrator-pipeline/tests/e2e/test_pipeline_graph.py @@ -0,0 +1,32 @@ +"""End-to-end tests for the crypto intelligence pipeline graph.""" + +from __future__ import annotations + +import pytest +from src.agents import graph as graph_module + + +@pytest.mark.asyncio +async def test_graph_executes_nodes_in_order(monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_research_planner(state: dict[str, str]) -> dict[str, str]: + assert state["input"] == "Research Arbitrum" + return {"plan": "1. Recent news\n2. Team background"} + + async def fake_news_scanner(state: dict[str, str]) -> dict[str, str]: + assert state["plan"] == "1. Recent news\n2. Team background" + return {"news": "Arbitrum announced L3 support. Community is bullish."} + + async def fake_intelligence_compiler(state: dict[str, str]) -> dict[str, str]: + assert state["news"] == "Arbitrum announced L3 support. Community is bullish." + return {"report": "## Executive Summary\nArbitrum is expanding its L2 ecosystem."} + + monkeypatch.setattr(graph_module, "research_planner_node", fake_research_planner) + monkeypatch.setattr(graph_module, "news_scanner_node", fake_news_scanner) + monkeypatch.setattr(graph_module, "intelligence_compiler_node", fake_intelligence_compiler) + + graph = graph_module.build_graph() + result = await graph.ainvoke({"input": "Research Arbitrum"}) + + assert result["plan"] == "1. Recent news\n2. Team background" + assert result["news"] == "Arbitrum announced L3 support. Community is bullish." + assert result["report"] == "## Executive Summary\nArbitrum is expanding its L2 ecosystem." diff --git a/examples/01-orchestrator-pipeline/tests/unit/test_agent_nodes.py b/examples/01-orchestrator-pipeline/tests/unit/test_agent_nodes.py new file mode 100644 index 0000000..f693905 --- /dev/null +++ b/examples/01-orchestrator-pipeline/tests/unit/test_agent_nodes.py @@ -0,0 +1,80 @@ +"""Unit tests for research_planner, news_scanner, and intelligence_compiler nodes.""" + +from __future__ import annotations + +from dataclasses import dataclass +from unittest.mock import AsyncMock, patch + +import pytest +from src.agents import intelligence_compiler, news_scanner, research_planner + + +@dataclass +class _DummyResponse: + content: str + + +class _DummyModel: + def __init__(self, response_text: str) -> None: + self._response_text = response_text + self.calls: list[list[object]] = [] + + async def ainvoke(self, messages: list[object]) -> _DummyResponse: + self.calls.append(messages) + return _DummyResponse(content=self._response_text) + + +@pytest.mark.asyncio +async def test_research_planner_returns_plan(monkeypatch: pytest.MonkeyPatch) -> None: + model = _DummyModel("1. Recent news\n2. Team background\n3. Technology") + monkeypatch.setattr(research_planner, "get_chat_model", lambda: model) + + result = await research_planner.research_planner_node({"input": "Research Arbitrum"}) + + assert result == {"plan": "1. Recent news\n2. Team background\n3. Technology"} + assert len(model.calls) == 1 + + sent_messages = model.calls[0] + assert "research planner" in str(sent_messages[0].content) + assert sent_messages[1].content == "Research Arbitrum" + + +@pytest.mark.asyncio +async def test_news_scanner_returns_analyzed_news(monkeypatch: pytest.MonkeyPatch) -> None: + model = _DummyModel("Arbitrum announced partnership with X. Community sentiment is bullish.") + monkeypatch.setattr(news_scanner, "get_chat_model", lambda: model) + + mock_search = AsyncMock() + mock_search.ainvoke = AsyncMock(return_value=[{"title": "Arbitrum news", "snippet": "Big partnership"}]) + with patch.object(news_scanner, "DuckDuckGoSearchResults", return_value=mock_search): + state = {"input": "Research Arbitrum", "plan": "1. Recent news\n2. Partnerships"} + result = await news_scanner.news_scanner_node(state) + + assert result == {"news": "Arbitrum announced partnership with X. Community sentiment is bullish."} + assert len(model.calls) == 1 + + sent_messages = model.calls[0] + assert "news analyst" in str(sent_messages[0].content).lower() + assert "Research Arbitrum" in str(sent_messages[1].content) + assert "1. Recent news" in str(sent_messages[1].content) + + +@pytest.mark.asyncio +async def test_intelligence_compiler_returns_report(monkeypatch: pytest.MonkeyPatch) -> None: + model = _DummyModel("## Executive Summary\nArbitrum is an L2 scaling solution.") + monkeypatch.setattr(intelligence_compiler, "get_chat_model", lambda: model) + + state = { + "input": "Research Arbitrum", + "plan": "1. News\n2. Team", + "news": "Partnership with Coinbase announced. Community bullish.", + } + result = await intelligence_compiler.intelligence_compiler_node(state) + + assert result == {"report": "## Executive Summary\nArbitrum is an L2 scaling solution."} + assert len(model.calls) == 1 + + sent_messages = model.calls[0] + assert "intelligence analyst" in str(sent_messages[0].content).lower() + assert "1. News\n2. Team" in str(sent_messages[1].content) + assert "Partnership with Coinbase" in str(sent_messages[1].content) diff --git a/examples/01-orchestrator-pipeline/tests/unit/test_state.py b/examples/01-orchestrator-pipeline/tests/unit/test_state.py new file mode 100644 index 0000000..c6df4e7 --- /dev/null +++ b/examples/01-orchestrator-pipeline/tests/unit/test_state.py @@ -0,0 +1,22 @@ +"""Unit tests for AgentState typed dict.""" + +from __future__ import annotations + +from src.agents.state import AgentState + + +def test_state_requires_input() -> None: + state: AgentState = {"input": "Research Arbitrum"} + assert state["input"] == "Research Arbitrum" + + +def test_state_optional_fields() -> None: + state: AgentState = { + "input": "Research Arbitrum", + "plan": "1. News\n2. Team", + "news": "Partnership announced", + "report": "## Executive Summary", + } + assert state["plan"] == "1. News\n2. Team" + assert state["news"] == "Partnership announced" + assert state["report"] == "## Executive Summary" diff --git a/examples/02-mcp-tool-integration/README.md b/examples/02-mcp-tool-integration/README.md new file mode 100644 index 0000000..0c270f2 --- /dev/null +++ b/examples/02-mcp-tool-integration/README.md @@ -0,0 +1,218 @@ +# Pattern 02: MCP Tool Integration + +> Give agents standardized access to external data sources via the Model Context Protocol. + +## What You'll Learn + +- How to build a custom MCP server that wraps an external API (CoinGecko) +- How to connect LangGraph agents as MCP clients using `langchain-mcp-adapters` +- The value of tool abstraction: agents don't know/care about the underlying API +- How to run multi-container setups with Docker Compose (agent + MCP server) +- How Claude Code can connect to the same MCP servers your agents use + +## The Problem + +In Pattern 01, the News Scanner calls DuckDuckGo directly -- the tool is a hardcoded Python function. This approach doesn't scale: + +- **New tools require code changes** in every agent that needs them +- **No sharing** -- Claude Code, Cursor, or other AI clients can't access the same tools +- **No standardization** -- each tool has a different interface, error handling, auth model + +**The solution**: Model Context Protocol (MCP). Build tools as MCP servers, and any MCP-compatible client -- your agents, Claude Code, Cursor -- can discover and use them through a standard protocol. + +## Architecture + +```mermaid +graph TD + User["User\n(POST /run)"] --> Agent["Agent Service\n(FastAPI :8000)"] + Agent --> Pipeline["LangGraph Pipeline\n(5 agents)"] + Pipeline -->|MCP client| CryptoMCP["crypto-data\nMCP Server\n(:8001)"] + CryptoMCP --> CoinGecko["CoinGecko API"] + Pipeline -->|direct| DDG["DuckDuckGo\n(web search)"] + ClaudeCode["Claude Code\n(MCP client)"] -->|MCP| CryptoMCP +``` + +## Key Concepts + +### MCP: The USB-C of AI Tools + +MCP standardizes how AI models access tools, similar to how USB-C standardized device connectivity. An MCP server exposes tools with typed schemas, and any MCP client can discover and call them. + +``` +MCP Server MCP Client ++------------------------+ +------------------+ +| search_coins(query) | <----> | LangGraph Agent | +| get_coin_info(coin_id) | | Claude Code | +| get_coin_price(coin_id)| | Cursor | ++------------------------+ +------------------+ +``` + +### Building an MCP Server + +```python +from mcp.server.fastmcp import FastMCP + +mcp = FastMCP("crypto-data") + +@mcp.tool() +async def get_coin_price(coin_id: str, vs_currency: str = "usd") -> str: + """Get current price, market cap, volume for a cryptocurrency.""" + data = await _coingecko_get("/simple/price", {"ids": coin_id, ...}) + return json.dumps(data) + +if __name__ == "__main__": + mcp.run(transport="sse") +``` + +### Connecting Agents as MCP Clients + +```python +from langchain_mcp_adapters.client import MultiServerMCPClient + +async with MultiServerMCPClient({ + "crypto-data": {"url": "http://crypto-data-mcp:8001/sse", "transport": "sse"} +}) as client: + tools = client.get_tools() + # tools are now standard LangChain tools -- use in any agent +``` + +### Pattern 01 vs Pattern 02 + +| Aspect | Pattern 01 (Direct) | Pattern 02 (MCP) | +|--------|-------------------|------------------| +| Tool access | Hardcoded Python calls | Standardized MCP protocol | +| Sharing | Only this codebase | Any MCP client (Claude Code, Cursor) | +| Adding tools | Code change in agents | Deploy new MCP server, agents discover it | +| Agents | 3 (minimal pipeline) | 5 (full Team 1) | +| New capabilities | None | CoinGecko project data, community stats | + +## Implementation + +### Step 1: Build the MCP Server + +The `crypto-data` MCP server wraps three CoinGecko API endpoints as MCP tools: + +- `search_coins(query)` -- find coins by name/symbol +- `get_coin_info(coin_id)` -- project description, links, community/developer stats +- `get_coin_price(coin_id)` -- current price, market cap, volume, 24h change + +### Step 2: MCP Client Setup + +A dedicated module (`mcp_setup.py`) manages the MCP client lifecycle: + +- `init_mcp()` -- connects to MCP servers at app startup +- `close_mcp()` -- disconnects at shutdown +- `get_mcp_tool(name)` -- agents call this to access tools + +### Step 3: Agents Use MCP Tools + +The Project Profiler agent uses MCP to get structured data: + +```python +async def project_profiler_node(state: AgentState) -> dict[str, str]: + search_tool = get_mcp_tool("search_coins") + search_results = await search_tool.ainvoke({"query": state["input"]}) + + info_tool = get_mcp_tool("get_coin_info") + coin_info = await info_tool.ainvoke({"coin_id": coin_id}) + + # LLM analyzes the structured data + llm = get_chat_model() + response = await llm.ainvoke([...]) + return {"profile": str(response.content)} +``` + +### Step 4: Multi-Container Docker Compose + +```yaml +services: + crypto-data-mcp: + command: ["python", "-m", "src.mcp_servers.crypto_data"] + ports: ["8001:8001"] + + agent: + environment: + - CRYPTO_DATA_MCP_URL=http://crypto-data-mcp:8001/sse + depends_on: + - crypto-data-mcp +``` + +### Claude Code Integration + +Add this to your Claude Code MCP config to use the same crypto-data tools: + +```json +{ + "mcpServers": { + "crypto-data": { + "url": "http://localhost:8001/sse", + "transport": "sse" + } + } +} +``` + +Now Claude Code can call `search_coins`, `get_coin_info`, and `get_coin_price` directly. + +## Running the Example + +```bash +# From the repository root +cp .env.example .env +# Fill in API keys + +# Run with Docker (starts MCP server + agent) +docker compose -f examples/02-mcp-tool-integration/docker-compose.yml up --build + +# Or with make +make example EX=02-mcp-tool-integration + +# Test endpoints +curl http://localhost:8000/health + +# Run the full intelligence pipeline +curl -X POST http://localhost:8000/run \ + -H "Content-Type: application/json" \ + -d '{"input": "Research the Arbitrum crypto project"}' +``` + +## Debug Walkthrough + +With `VERBOSE=true`, you'll see MCP tool calls in the agent output: + +``` +[14:32:01] [MCP] Connecting to MCP servers: ['crypto-data'] +[14:32:01] [MCP] Loaded 3 tools: ['search_coins', 'get_coin_info', 'get_coin_price'] +[14:32:01] [System] FastAPI application started with MCP connections +[14:32:05] [ResearchPlanner] Planning research for: Research Arbitrum +[14:32:07] [NewsScanner] Searching for: Research Arbitrum +[14:32:10] [ProjectProfiler] Profiling: Research Arbitrum +[14:32:10] [ProjectProfiler] Coin search returned: [{"id": "arbitrum", ...}] +[14:32:11] [ProjectProfiler] Got coin info and price data via MCP +[14:32:14] [CommunityAnalyst] Got community/developer data via MCP +[14:32:17] [IntelligenceCompiler] Report generated (1247 chars) +``` + +## Exercises + +1. **Add a new MCP tool**: Add `get_market_chart(coin_id, days)` to the crypto-data MCP server that returns price history. Modify the Community Analyst to use it. +2. **Build a second MCP server**: Create a `web-search` MCP server wrapping DuckDuckGo. Migrate the News Scanner from direct calls to MCP. +3. **Test with Claude Code**: Configure Claude Code to connect to your crypto-data MCP server and ask it questions about crypto projects. + +## Trade-offs + +| Advantage | Limitation | +|-----------|-----------| +| Standard protocol -- any MCP client works | Extra network hop (agent -> MCP server -> API) | +| Tools shared across agents and AI clients | MCP server becomes a dependency to manage | +| Adding tools doesn't require agent changes | Connection lifecycle management adds complexity | +| Clean separation: data access vs. intelligence | CoinGecko rate limits apply (30 req/min free tier) | + +**Next pattern** (Pattern 03) adds persistent memory so the system remembers previous research across conversations. + +## Further Reading + +- [MCP Specification](https://modelcontextprotocol.io/) +- [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) +- [langchain-mcp-adapters](https://github.com/langchain-ai/langchain-mcp-adapters) +- [CoinGecko Free API](https://docs.coingecko.com/reference/introduction) diff --git a/examples/02-mcp-tool-integration/docker-compose.yml b/examples/02-mcp-tool-integration/docker-compose.yml new file mode 100644 index 0000000..e9583a1 --- /dev/null +++ b/examples/02-mcp-tool-integration/docker-compose.yml @@ -0,0 +1,37 @@ +services: + crypto-data-mcp: + build: + context: ../.. + dockerfile: infra/docker/base/Dockerfile.agent + args: + EXAMPLE_DIR: examples/02-mcp-tool-integration + command: ["python", "-m", "src.mcp_servers.crypto_data"] + ports: + - "8001:8001" + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8001/sse').raise_for_status()"] + interval: 10s + timeout: 5s + retries: 3 + + agent: + build: + context: ../.. + dockerfile: infra/docker/base/Dockerfile.agent + args: + EXAMPLE_DIR: examples/02-mcp-tool-integration + ports: + - "8000:8000" + env_file: + - ../../.env + environment: + - VERBOSE=${VERBOSE:-true} + - CRYPTO_DATA_MCP_URL=http://crypto-data-mcp:8001/sse + depends_on: + crypto-data-mcp: + condition: service_started + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"] + interval: 10s + timeout: 5s + retries: 3 diff --git a/examples/02-mcp-tool-integration/pyproject.toml b/examples/02-mcp-tool-integration/pyproject.toml new file mode 100644 index 0000000..322bace --- /dev/null +++ b/examples/02-mcp-tool-integration/pyproject.toml @@ -0,0 +1,24 @@ +[project] +name = "example-02-mcp-tool-integration" +version = "0.1.0" +description = "Pattern 02: MCP Tool Integration -- standardized tool access via Model Context Protocol" +requires-python = ">=3.14" +dependencies = [ + "langgraph>=0.4", + "langchain-core>=0.3", + "langchain-community>=0.3", + "langchain-openai>=0.3", + "langchain-anthropic>=0.3", + "langchain-mcp-adapters>=0.2", + "langsmith>=0.3", + "mcp>=1.0", + "duckduckgo-search>=8.0", + "fastapi>=0.115", + "uvicorn>=0.34", + "pydantic>=2.0", + "httpx>=0.28", + "agent-common", +] + +[tool.uv.sources] +agent-common = { workspace = true } diff --git a/examples/01-multi-agent-single-system/tests/__init__.py b/examples/02-mcp-tool-integration/src/__init__.py similarity index 100% rename from examples/01-multi-agent-single-system/tests/__init__.py rename to examples/02-mcp-tool-integration/src/__init__.py diff --git a/examples/01-multi-agent-single-system/src/agents/__init__.py b/examples/02-mcp-tool-integration/src/agents/__init__.py similarity index 50% rename from examples/01-multi-agent-single-system/src/agents/__init__.py rename to examples/02-mcp-tool-integration/src/agents/__init__.py index d380c0c..110ff0c 100644 --- a/examples/01-multi-agent-single-system/src/agents/__init__.py +++ b/examples/02-mcp-tool-integration/src/agents/__init__.py @@ -1,4 +1,4 @@ -"""Multi-agent pipeline: planner, researcher, writer.""" +"""Full Team 1 intelligence pipeline with MCP tool integration.""" from src.agents.graph import build_graph diff --git a/examples/02-mcp-tool-integration/src/agents/community_analyst.py b/examples/02-mcp-tool-integration/src/agents/community_analyst.py new file mode 100644 index 0000000..9a715f0 --- /dev/null +++ b/examples/02-mcp-tool-integration/src/agents/community_analyst.py @@ -0,0 +1,62 @@ +"""Community Analyst agent -- analyzes community and developer activity via MCP. + +Uses the crypto-data MCP server to access community/developer stats and +combines with LLM analysis for a community health assessment. +""" + +from __future__ import annotations + +import json + +from agent_common.llm import get_chat_model +from agent_common.tracing import verbose_log +from langchain_core.messages import HumanMessage, SystemMessage + +from src.agents.state import AgentState +from src.mcp_setup import get_mcp_tool + +SYSTEM_PROMPT = """\ +You are a crypto community analyst. You receive community and developer data +about a crypto project from CoinGecko. + +Assess the project's community health: +- Developer activity: GitHub commits, contributors, forks, stars +- Community size: Twitter followers, Reddit subscribers, Telegram users +- Activity trends: is the community growing or shrinking? +- Red flags: low developer activity, declining community, etc. + +Provide a brief community health rating (Strong / Moderate / Weak) with justification.""" + + +async def community_analyst_node(state: AgentState) -> dict[str, str]: + """Analyze community and developer activity using MCP tools.""" + user_input = state["input"] + verbose_log("CommunityAnalyst", f"Analyzing community for: {user_input[:80]}") + + search_tool = get_mcp_tool("search_coins") + search_results = await search_tool.ainvoke({"query": user_input}) + + coins = json.loads(search_results) if isinstance(search_results, str) else search_results + coin_id = coins[0]["id"] if coins else user_input.lower().replace(" ", "-") + + info_tool = get_mcp_tool("get_coin_info") + coin_info = await info_tool.ainvoke({"coin_id": coin_id}) + verbose_log("CommunityAnalyst", "Got community/developer data via MCP") + + llm = get_chat_model() + response = await llm.ainvoke( + [ + SystemMessage(content=SYSTEM_PROMPT), + HumanMessage( + content=( + f"Project: {user_input}\n\n" + f"CoinGecko project data (includes community_data and developer_data):\n{coin_info}" + ) + ), + ] + ) + + community = str(response.content) + verbose_log("CommunityAnalyst", f"Community analysis complete ({len(community)} chars)") + + return {"community": community} diff --git a/examples/02-mcp-tool-integration/src/agents/graph.py b/examples/02-mcp-tool-integration/src/agents/graph.py new file mode 100644 index 0000000..e7ceeb8 --- /dev/null +++ b/examples/02-mcp-tool-integration/src/agents/graph.py @@ -0,0 +1,49 @@ +"""LangGraph StateGraph wiring for the full Team 1 intelligence pipeline. + +Pipeline: research_planner -> news_scanner -> project_profiler -> community_analyst -> intelligence_compiler + +The news_scanner uses DuckDuckGo directly. The project_profiler and community_analyst +use MCP tools to access CoinGecko data through the crypto-data MCP server. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from agent_common.tracing import verbose_log +from langgraph.graph import END, StateGraph + +from src.agents.community_analyst import community_analyst_node +from src.agents.intelligence_compiler import intelligence_compiler_node +from src.agents.news_scanner import news_scanner_node +from src.agents.project_profiler import project_profiler_node +from src.agents.research_planner import research_planner_node +from src.agents.state import AgentState + +if TYPE_CHECKING: + from langgraph.graph.state import CompiledStateGraph + + +def build_graph() -> CompiledStateGraph: # type: ignore[type-arg] + """Build and compile the full crypto intelligence pipeline graph.""" + verbose_log( + "System", + "Building graph: research_planner -> news_scanner -> project_profiler -> community_analyst -> compiler", + ) + + graph = StateGraph(AgentState) + + graph.add_node("research_planner", research_planner_node) + graph.add_node("news_scanner", news_scanner_node) + graph.add_node("project_profiler", project_profiler_node) + graph.add_node("community_analyst", community_analyst_node) + graph.add_node("intelligence_compiler", intelligence_compiler_node) + + graph.set_entry_point("research_planner") + graph.add_edge("research_planner", "news_scanner") + graph.add_edge("news_scanner", "project_profiler") + graph.add_edge("project_profiler", "community_analyst") + graph.add_edge("community_analyst", "intelligence_compiler") + graph.add_edge("intelligence_compiler", END) + + return graph.compile() diff --git a/examples/02-mcp-tool-integration/src/agents/intelligence_compiler.py b/examples/02-mcp-tool-integration/src/agents/intelligence_compiler.py new file mode 100644 index 0000000..800b289 --- /dev/null +++ b/examples/02-mcp-tool-integration/src/agents/intelligence_compiler.py @@ -0,0 +1,51 @@ +"""Intelligence Compiler agent -- synthesizes all research into a structured report.""" + +from __future__ import annotations + +from agent_common.llm import get_chat_model +from agent_common.tracing import verbose_log +from langchain_core.messages import HumanMessage, SystemMessage + +from src.agents.state import AgentState + +SYSTEM_PROMPT = """\ +You are a senior crypto intelligence analyst. You receive a research plan, news analysis, +a project profile (with market data), and community health assessment. + +Produce a comprehensive intelligence report with these sections: +1. **Executive Summary** -- 2-3 sentence overview of the project +2. **Market Snapshot** -- Current price, market cap, 24h change, volume +3. **Key Findings** -- Top 5 most important discoveries across all research +4. **Recent Developments** -- Notable news, partnerships, milestones +5. **Community & Development Health** -- Developer activity, community engagement +6. **Risk Factors** -- Potential concerns, red flags, or uncertainties +7. **Outlook** -- Forward-looking assessment with confidence level + +Keep the report factual, professional, and under 600 words. +Clearly distinguish between verified facts and speculation.""" + + +async def intelligence_compiler_node(state: AgentState) -> dict[str, str]: + """Compile all research findings into a structured intelligence report.""" + verbose_log("IntelligenceCompiler", "Compiling intelligence report from all sources") + + llm = get_chat_model() + response = await llm.ainvoke( + [ + SystemMessage(content=SYSTEM_PROMPT), + HumanMessage( + content=( + f"Crypto project: {state['input']}\n\n" + f"Research plan:\n{state.get('plan', 'N/A')}\n\n" + f"News analysis:\n{state.get('news', 'N/A')}\n\n" + f"Project profile:\n{state.get('profile', 'N/A')}\n\n" + f"Community assessment:\n{state.get('community', 'N/A')}" + ) + ), + ] + ) + + report = str(response.content) + verbose_log("IntelligenceCompiler", f"Report generated ({len(report)} chars)") + + return {"report": report} diff --git a/examples/02-mcp-tool-integration/src/agents/news_scanner.py b/examples/02-mcp-tool-integration/src/agents/news_scanner.py new file mode 100644 index 0000000..76570db --- /dev/null +++ b/examples/02-mcp-tool-integration/src/agents/news_scanner.py @@ -0,0 +1,49 @@ +"""News Scanner agent -- searches the web for recent news about a crypto project. + +Uses DuckDuckGo web search directly (not through MCP). This agent demonstrates +that MCP and direct tools can coexist -- Pattern 02 introduces MCP for new +capabilities while keeping existing integrations. +""" + +from __future__ import annotations + +from agent_common.llm import get_chat_model +from agent_common.tracing import verbose_log +from langchain_community.tools import DuckDuckGoSearchResults +from langchain_core.messages import HumanMessage, SystemMessage + +from src.agents.state import AgentState + +SYSTEM_PROMPT = """\ +You are a crypto news analyst. You receive raw web search results about a crypto project. + +Extract and organize the most relevant recent information: +- Key news and announcements (with approximate dates if available) +- Partnership or integration announcements +- Regulatory or legal developments +- Notable community reactions or events + +Be factual. Note which claims are well-sourced vs. speculative.""" + + +async def news_scanner_node(state: AgentState) -> dict[str, str]: + """Search the web for crypto project news and analyze results.""" + user_input = state["input"] + verbose_log("NewsScanner", f"Searching for: {user_input[:80]}") + + search = DuckDuckGoSearchResults(max_results=8, output_format="list") # type: ignore[call-arg] + raw_results = await search.ainvoke(f"{user_input} crypto project latest news 2026") + verbose_log("NewsScanner", f"Got {len(raw_results) if isinstance(raw_results, list) else '?'} search results") + + llm = get_chat_model() + response = await llm.ainvoke( + [ + SystemMessage(content=SYSTEM_PROMPT), + HumanMessage(content=f"Crypto project: {user_input}\n\nWeb search results:\n{raw_results}"), + ] + ) + + news = str(response.content) + verbose_log("NewsScanner", f"News analysis complete ({len(news)} chars)") + + return {"news": news} diff --git a/examples/02-mcp-tool-integration/src/agents/project_profiler.py b/examples/02-mcp-tool-integration/src/agents/project_profiler.py new file mode 100644 index 0000000..90bac0b --- /dev/null +++ b/examples/02-mcp-tool-integration/src/agents/project_profiler.py @@ -0,0 +1,67 @@ +"""Project Profiler agent -- gathers project fundamentals via MCP tools. + +This agent uses the crypto-data MCP server to get structured project information +from CoinGecko, demonstrating MCP-based tool access. +""" + +from __future__ import annotations + +from agent_common.llm import get_chat_model +from agent_common.tracing import verbose_log +from langchain_core.messages import HumanMessage, SystemMessage + +from src.agents.state import AgentState +from src.mcp_setup import get_mcp_tool + +SYSTEM_PROMPT = """\ +You are a crypto project profiler. You receive structured data from CoinGecko +about a crypto project (info and current price). + +Create a concise project profile covering: +- What the project does (technology, use case) +- Key stats: market cap, current price, 24h change +- Project maturity: genesis date, development activity +- Category positioning and notable links + +Be factual and quantitative where possible.""" + + +async def project_profiler_node(state: AgentState) -> dict[str, str]: + """Gather project fundamentals using MCP crypto-data tools.""" + user_input = state["input"] + verbose_log("ProjectProfiler", f"Profiling: {user_input[:80]}") + + search_tool = get_mcp_tool("search_coins") + search_results = await search_tool.ainvoke({"query": user_input}) + verbose_log("ProjectProfiler", f"Coin search returned: {str(search_results)[:200]}") + + import json + + coins = json.loads(search_results) if isinstance(search_results, str) else search_results + coin_id = coins[0]["id"] if coins else user_input.lower().replace(" ", "-") + + info_tool = get_mcp_tool("get_coin_info") + price_tool = get_mcp_tool("get_coin_price") + + coin_info = await info_tool.ainvoke({"coin_id": coin_id}) + coin_price = await price_tool.ainvoke({"coin_id": coin_id}) + verbose_log("ProjectProfiler", "Got coin info and price data via MCP") + + llm = get_chat_model() + response = await llm.ainvoke( + [ + SystemMessage(content=SYSTEM_PROMPT), + HumanMessage( + content=( + f"Project query: {user_input}\n\n" + f"CoinGecko project info:\n{coin_info}\n\n" + f"Current price data:\n{coin_price}" + ) + ), + ] + ) + + profile = str(response.content) + verbose_log("ProjectProfiler", f"Profile complete ({len(profile)} chars)") + + return {"profile": profile} diff --git a/examples/02-mcp-tool-integration/src/agents/research_planner.py b/examples/02-mcp-tool-integration/src/agents/research_planner.py new file mode 100644 index 0000000..9a416ca --- /dev/null +++ b/examples/02-mcp-tool-integration/src/agents/research_planner.py @@ -0,0 +1,40 @@ +"""Research Planner agent -- creates a structured research plan for a crypto project.""" + +from __future__ import annotations + +from agent_common.llm import get_chat_model +from agent_common.tracing import verbose_log +from langchain_core.messages import HumanMessage, SystemMessage + +from src.agents.state import AgentState + +SYSTEM_PROMPT = """\ +You are a crypto project research planner. Given a crypto project name or topic, +create a focused research plan covering these areas: + +1. Recent news, announcements, and partnerships +2. Project fundamentals: goals, technology, roadmap, team +3. Community and developer activity: GitHub commits, social media sentiment +4. Market positioning: categories, competitors, unique value proposition + +Output a numbered list with one sentence per area describing what to investigate. +Keep it concise and actionable.""" + + +async def research_planner_node(state: AgentState) -> dict[str, str]: + """Create a structured research plan for the crypto project.""" + user_input = state["input"] + verbose_log("ResearchPlanner", f"Planning research for: {user_input[:100]}") + + llm = get_chat_model() + response = await llm.ainvoke( + [ + SystemMessage(content=SYSTEM_PROMPT), + HumanMessage(content=user_input), + ] + ) + + plan = str(response.content) + verbose_log("ResearchPlanner", f"Plan created ({len(plan)} chars)") + + return {"plan": plan} diff --git a/examples/02-mcp-tool-integration/src/agents/state.py b/examples/02-mcp-tool-integration/src/agents/state.py new file mode 100644 index 0000000..cdc6054 --- /dev/null +++ b/examples/02-mcp-tool-integration/src/agents/state.py @@ -0,0 +1,14 @@ +"""Typed state shared across all agent nodes in the full intelligence pipeline.""" + +from __future__ import annotations + +from typing import Required, TypedDict + + +class AgentState(TypedDict, total=False): + input: Required[str] + plan: str + news: str + profile: str + community: str + report: str diff --git a/examples/02-mcp-tool-integration/src/app.py b/examples/02-mcp-tool-integration/src/app.py new file mode 100644 index 0000000..cee09aa --- /dev/null +++ b/examples/02-mcp-tool-integration/src/app.py @@ -0,0 +1,66 @@ +"""FastAPI application exposing the full Team 1 intelligence pipeline with MCP tools.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from agent_common.tracing import setup_tracing, verbose_log +from fastapi import FastAPI +from pydantic import BaseModel + +from src.agents.graph import build_graph +from src.mcp_setup import close_mcp, init_mcp + + +@asynccontextmanager +async def lifespan(_app: FastAPI) -> AsyncIterator[None]: + setup_tracing() + await init_mcp() + verbose_log("System", "FastAPI application started with MCP connections") + yield + await close_mcp() + verbose_log("System", "FastAPI application shutting down") + + +app = FastAPI( + title="Pattern 02: MCP Tool Integration", + description="Full Team 1 intelligence pipeline (5 agents) with MCP-based tool access to CoinGecko data.", + version="0.1.0", + lifespan=lifespan, +) + + +class RunRequest(BaseModel): + input: str + + +class RunResponse(BaseModel): + report: str + plan: str + news: str + profile: str + community: str + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +@app.post("/run", response_model=RunResponse) +async def run(request: RunRequest) -> RunResponse: + verbose_log("System", f"Received request: {request.input[:100]}") + + graph = build_graph() + result = await graph.ainvoke({"input": request.input}) + + verbose_log("System", "Pipeline complete, returning response") + + return RunResponse( + report=result.get("report", ""), + plan=result.get("plan", ""), + news=result.get("news", ""), + profile=result.get("profile", ""), + community=result.get("community", ""), + ) diff --git a/examples/02-mcp-tool-integration/src/mcp_servers/__init__.py b/examples/02-mcp-tool-integration/src/mcp_servers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/02-mcp-tool-integration/src/mcp_servers/crypto_data.py b/examples/02-mcp-tool-integration/src/mcp_servers/crypto_data.py new file mode 100644 index 0000000..a12ef88 --- /dev/null +++ b/examples/02-mcp-tool-integration/src/mcp_servers/crypto_data.py @@ -0,0 +1,100 @@ +"""MCP server exposing CoinGecko crypto data as tools. + +Run standalone: python -m src.mcp_servers.crypto_data +Connects to the free CoinGecko API (no API key required, rate-limited ~30 req/min). +""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +from mcp.server.fastmcp import FastMCP + +COINGECKO_BASE = "https://api.coingecko.com/api/v3" + +mcp = FastMCP("crypto-data") + + +async def _coingecko_get(path: str, params: dict[str, str] | None = None) -> Any: + """Make a GET request to CoinGecko API.""" + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.get(f"{COINGECKO_BASE}{path}", params=params or {}) + resp.raise_for_status() + return resp.json() # noqa: ANN401 + + +@mcp.tool() +async def search_coins(query: str) -> str: + """Search for cryptocurrency projects by name or symbol. + + Returns a list of matching coins with their IDs (use the ID for other tools). + """ + data = await _coingecko_get("/search", {"query": query}) + coins = data.get("coins", [])[:8] + results = [ + {"id": c["id"], "name": c["name"], "symbol": c["symbol"], "market_cap_rank": c.get("market_cap_rank")} + for c in coins + ] + return json.dumps(results, indent=2) + + +@mcp.tool() +async def get_coin_info(coin_id: str) -> str: + """Get detailed information about a cryptocurrency project. + + Returns: name, symbol, description, categories, links, genesis date, + and developer/community stats. Use the coin ID from search_coins. + """ + data = await _coingecko_get( + f"/coins/{coin_id}", + {"localization": "false", "tickers": "false", "market_data": "false", "community_data": "true"}, + ) + info = { + "name": data.get("name"), + "symbol": data.get("symbol"), + "description": (data.get("description", {}).get("en", ""))[:1500], + "categories": data.get("categories", []), + "genesis_date": data.get("genesis_date"), + "homepage": data.get("links", {}).get("homepage", [None])[0], + "github": data.get("links", {}).get("repos_url", {}).get("github", []), + "twitter": data.get("links", {}).get("twitter_screen_name"), + "community_data": data.get("community_data", {}), + "developer_data": { + k: v for k, v in data.get("developer_data", {}).items() if isinstance(v, (int, float)) and v > 0 + }, + } + return json.dumps(info, indent=2, default=str) + + +@mcp.tool() +async def get_coin_price(coin_id: str, vs_currency: str = "usd") -> str: + """Get current price, market cap, volume, and 24h change for a cryptocurrency. + + Use the coin ID from search_coins (e.g., 'bitcoin', 'ethereum', 'arbitrum'). + """ + data = await _coingecko_get( + "/simple/price", + { + "ids": coin_id, + "vs_currencies": vs_currency, + "include_market_cap": "true", + "include_24hr_vol": "true", + "include_24hr_change": "true", + }, + ) + coin_data = data.get(coin_id, {}) + price_info = { + "coin_id": coin_id, + "currency": vs_currency, + "price": coin_data.get(vs_currency), + "market_cap": coin_data.get(f"{vs_currency}_market_cap"), + "volume_24h": coin_data.get(f"{vs_currency}_24h_vol"), + "change_24h_pct": coin_data.get(f"{vs_currency}_24h_change"), + } + return json.dumps(price_info, indent=2) + + +if __name__ == "__main__": + mcp.run(transport="sse") diff --git a/examples/02-mcp-tool-integration/src/mcp_setup.py b/examples/02-mcp-tool-integration/src/mcp_setup.py new file mode 100644 index 0000000..3209dcd --- /dev/null +++ b/examples/02-mcp-tool-integration/src/mcp_setup.py @@ -0,0 +1,63 @@ +"""MCP client lifecycle management. + +Initializes connections to MCP servers at startup and provides tool access to agents. +""" + +from __future__ import annotations + +import os +from typing import Any + +from agent_common.tracing import verbose_log +from langchain_core.tools import BaseTool +from langchain_mcp_adapters.client import MultiServerMCPClient + +_mcp_client: MultiServerMCPClient | None = None +_mcp_tools: dict[str, BaseTool] = {} + + +def _get_mcp_config() -> dict[str, dict[str, Any]]: + crypto_data_url = os.environ.get("CRYPTO_DATA_MCP_URL", "http://localhost:8001/sse") + return { + "crypto-data": { + "url": crypto_data_url, + "transport": "sse", + }, + } + + +async def init_mcp() -> None: + """Connect to all MCP servers and load available tools.""" + global _mcp_client, _mcp_tools # noqa: PLW0603 + config = _get_mcp_config() + verbose_log("MCP", f"Connecting to MCP servers: {list(config.keys())}") + + client: MultiServerMCPClient = MultiServerMCPClient(config) # type: ignore[arg-type] + _mcp_client = await client.__aenter__() + tools: list[BaseTool] = await _mcp_client.get_tools() # type: ignore[misc] + _mcp_tools = {t.name: t for t in tools} + verbose_log("MCP", f"Loaded {len(_mcp_tools)} tools: {list(_mcp_tools.keys())}") + + +async def close_mcp() -> None: + """Disconnect from all MCP servers.""" + global _mcp_client, _mcp_tools # noqa: PLW0603 + if _mcp_client: + await _mcp_client.__aexit__(None, None, None) # type: ignore[func-returns-value] + _mcp_client = None + _mcp_tools = {} + verbose_log("MCP", "Disconnected from MCP servers") + + +def get_mcp_tool(name: str) -> BaseTool: + """Get an MCP tool by name. Raises KeyError if not found.""" + if name not in _mcp_tools: + available = list(_mcp_tools.keys()) + msg = f"MCP tool '{name}' not found. Available: {available}" + raise KeyError(msg) + return _mcp_tools[name] + + +def get_all_mcp_tools() -> list[BaseTool]: + """Get all available MCP tools.""" + return list(_mcp_tools.values()) diff --git a/examples/02-mcp-tool-integration/tests/api/test_app_api.py b/examples/02-mcp-tool-integration/tests/api/test_app_api.py new file mode 100644 index 0000000..4f2aa6c --- /dev/null +++ b/examples/02-mcp-tool-integration/tests/api/test_app_api.py @@ -0,0 +1,71 @@ +"""API tests for Pattern 02 FastAPI endpoints.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient +from src import app as app_module + + +@dataclass +class _FakeGraph: + result: dict[str, Any] + received_input: dict[str, str] | None = None + + async def ainvoke(self, payload: dict[str, str]) -> dict[str, Any]: + self.received_input = payload + return self.result + + +def test_health_endpoint_returns_ok() -> None: + with ( + patch.object(app_module, "init_mcp", new_callable=AsyncMock), + patch.object(app_module, "close_mcp", new_callable=AsyncMock), + TestClient(app_module.app) as client, + ): + response = client.get("/health") + + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + +def test_run_endpoint_executes_graph(monkeypatch: pytest.MonkeyPatch) -> None: + fake_graph = _FakeGraph( + result={ + "report": "## Executive Summary\nArbitrum report.", + "plan": "1. News\n2. Profile", + "news": "Orbit chains launched.", + "profile": "L2 rollup, $1.23", + "community": "Strong community health.", + } + ) + monkeypatch.setattr(app_module, "build_graph", lambda: fake_graph) + + with ( + patch.object(app_module, "init_mcp", new_callable=AsyncMock), + patch.object(app_module, "close_mcp", new_callable=AsyncMock), + TestClient(app_module.app) as client, + ): + response = client.post("/run", json={"input": "Research Arbitrum"}) + + assert response.status_code == 200 + data = response.json() + assert data["report"] == "## Executive Summary\nArbitrum report." + assert data["profile"] == "L2 rollup, $1.23" + assert data["community"] == "Strong community health." + assert fake_graph.received_input == {"input": "Research Arbitrum"} + + +def test_run_endpoint_validates_request() -> None: + with ( + patch.object(app_module, "init_mcp", new_callable=AsyncMock), + patch.object(app_module, "close_mcp", new_callable=AsyncMock), + TestClient(app_module.app) as client, + ): + response = client.post("/run", json={}) + + assert response.status_code == 422 diff --git a/examples/02-mcp-tool-integration/tests/conftest.py b/examples/02-mcp-tool-integration/tests/conftest.py new file mode 100644 index 0000000..4e6e9df --- /dev/null +++ b/examples/02-mcp-tool-integration/tests/conftest.py @@ -0,0 +1,18 @@ +"""Pytest configuration for example 02 tests.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +EXAMPLE_ROOT = Path(__file__).resolve().parents[1] + +for key in list(sys.modules.keys()): + if key == "src" or key.startswith("src."): + mod = sys.modules[key] + mod_file = getattr(mod, "__file__", None) or "" + if mod_file and str(EXAMPLE_ROOT) not in mod_file: + del sys.modules[key] + +if str(EXAMPLE_ROOT) not in sys.path: + sys.path.insert(0, str(EXAMPLE_ROOT)) diff --git a/examples/02-mcp-tool-integration/tests/e2e/test_pipeline_graph.py b/examples/02-mcp-tool-integration/tests/e2e/test_pipeline_graph.py new file mode 100644 index 0000000..6fbd441 --- /dev/null +++ b/examples/02-mcp-tool-integration/tests/e2e/test_pipeline_graph.py @@ -0,0 +1,57 @@ +"""End-to-end tests for the full Team 1 intelligence pipeline graph.""" + +from __future__ import annotations + +import pytest +from src.agents import graph as graph_module + + +@pytest.mark.asyncio +async def test_graph_executes_all_five_nodes(monkeypatch: pytest.MonkeyPatch) -> None: + call_order: list[str] = [] + + async def fake_research_planner(state: dict[str, str]) -> dict[str, str]: + call_order.append("research_planner") + assert state["input"] == "Research Arbitrum" + return {"plan": "1. News\n2. Profile\n3. Community"} + + async def fake_news_scanner(state: dict[str, str]) -> dict[str, str]: + call_order.append("news_scanner") + assert state["plan"] == "1. News\n2. Profile\n3. Community" + return {"news": "Orbit chains launched. TVL over $10B."} + + async def fake_project_profiler(state: dict[str, str]) -> dict[str, str]: + call_order.append("project_profiler") + assert "Orbit chains" in state["news"] + return {"profile": "L2 optimistic rollup. Price $1.23, Market cap $4.5B."} + + async def fake_community_analyst(state: dict[str, str]) -> dict[str, str]: + call_order.append("community_analyst") + assert "L2 optimistic rollup" in state["profile"] + return {"community": "Strong: 500k Twitter, 1200 commits/month."} + + async def fake_intelligence_compiler(state: dict[str, str]) -> dict[str, str]: + call_order.append("intelligence_compiler") + assert state["news"] is not None + assert state["profile"] is not None + assert state["community"] is not None + return {"report": "## Executive Summary\nArbitrum comprehensive intelligence report."} + + monkeypatch.setattr(graph_module, "research_planner_node", fake_research_planner) + monkeypatch.setattr(graph_module, "news_scanner_node", fake_news_scanner) + monkeypatch.setattr(graph_module, "project_profiler_node", fake_project_profiler) + monkeypatch.setattr(graph_module, "community_analyst_node", fake_community_analyst) + monkeypatch.setattr(graph_module, "intelligence_compiler_node", fake_intelligence_compiler) + + graph = graph_module.build_graph() + result = await graph.ainvoke({"input": "Research Arbitrum"}) + + assert call_order == [ + "research_planner", + "news_scanner", + "project_profiler", + "community_analyst", + "intelligence_compiler", + ] + assert "Executive Summary" in result["report"] + assert result["profile"] == "L2 optimistic rollup. Price $1.23, Market cap $4.5B." diff --git a/examples/02-mcp-tool-integration/tests/unit/test_agent_nodes.py b/examples/02-mcp-tool-integration/tests/unit/test_agent_nodes.py new file mode 100644 index 0000000..5d9eedc --- /dev/null +++ b/examples/02-mcp-tool-integration/tests/unit/test_agent_nodes.py @@ -0,0 +1,130 @@ +"""Unit tests for all 5 agent nodes in Pattern 02.""" + +from __future__ import annotations + +from dataclasses import dataclass +from unittest.mock import AsyncMock, patch + +import pytest +from src.agents import ( + community_analyst, + intelligence_compiler, + news_scanner, + project_profiler, + research_planner, +) + + +@dataclass +class _DummyResponse: + content: str + + +class _DummyModel: + def __init__(self, response_text: str) -> None: + self._response_text = response_text + self.calls: list[list[object]] = [] + + async def ainvoke(self, messages: list[object]) -> _DummyResponse: + self.calls.append(messages) + return _DummyResponse(content=self._response_text) + + +@pytest.mark.asyncio +async def test_research_planner_returns_plan(monkeypatch: pytest.MonkeyPatch) -> None: + model = _DummyModel("1. Recent news\n2. Project fundamentals\n3. Community activity") + monkeypatch.setattr(research_planner, "get_chat_model", lambda: model) + + result = await research_planner.research_planner_node({"input": "Research Arbitrum"}) + + assert result == {"plan": "1. Recent news\n2. Project fundamentals\n3. Community activity"} + assert len(model.calls) == 1 + + +@pytest.mark.asyncio +async def test_news_scanner_returns_news(monkeypatch: pytest.MonkeyPatch) -> None: + model = _DummyModel("Arbitrum announced Orbit chains. TVL exceeded $10B.") + monkeypatch.setattr(news_scanner, "get_chat_model", lambda: model) + + mock_search = AsyncMock() + mock_search.ainvoke = AsyncMock(return_value=[{"title": "Arbitrum news", "snippet": "Orbit chains launched"}]) + with patch.object(news_scanner, "DuckDuckGoSearchResults", return_value=mock_search): + result = await news_scanner.news_scanner_node({"input": "Research Arbitrum", "plan": "1. News\n2. Tech"}) + + assert "Orbit chains" in result["news"] + assert len(model.calls) == 1 + mock_search.ainvoke.assert_called_once() + + +@pytest.mark.asyncio +async def test_project_profiler_uses_mcp_tools(monkeypatch: pytest.MonkeyPatch) -> None: + model = _DummyModel("Arbitrum is an L2 optimistic rollup. Price: $1.23, Market cap: $4.5B") + monkeypatch.setattr(project_profiler, "get_chat_model", lambda: model) + + mock_search = AsyncMock() + mock_search.ainvoke = AsyncMock(return_value='[{"id": "arbitrum", "name": "Arbitrum", "symbol": "ARB"}]') + mock_info = AsyncMock() + mock_info.ainvoke = AsyncMock(return_value='{"name": "Arbitrum", "description": "L2 rollup"}') + mock_price = AsyncMock() + mock_price.ainvoke = AsyncMock(return_value='{"price": 1.23, "market_cap": 4500000000}') + + monkeypatch.setattr( + project_profiler, + "get_mcp_tool", + lambda name: { + "search_coins": mock_search, + "get_coin_info": mock_info, + "get_coin_price": mock_price, + }[name], + ) + + result = await project_profiler.project_profiler_node({"input": "Research Arbitrum"}) + + assert "L2 optimistic rollup" in result["profile"] + mock_search.ainvoke.assert_called_once() + mock_info.ainvoke.assert_called_once() + mock_price.ainvoke.assert_called_once() + + +@pytest.mark.asyncio +async def test_community_analyst_uses_mcp_tools(monkeypatch: pytest.MonkeyPatch) -> None: + model = _DummyModel("Community Health: Strong. Active GitHub with 500+ contributors.") + monkeypatch.setattr(community_analyst, "get_chat_model", lambda: model) + + mock_search = AsyncMock() + mock_search.ainvoke = AsyncMock(return_value='[{"id": "arbitrum", "name": "Arbitrum", "symbol": "ARB"}]') + mock_info = AsyncMock() + mock_info.ainvoke = AsyncMock( + return_value='{"community_data": {"twitter_followers": 500000}, "developer_data": {"commits": 1200}}' + ) + + monkeypatch.setattr( + community_analyst, + "get_mcp_tool", + lambda name: { + "search_coins": mock_search, + "get_coin_info": mock_info, + }[name], + ) + + result = await community_analyst.community_analyst_node({"input": "Research Arbitrum"}) + + assert "Strong" in result["community"] + + +@pytest.mark.asyncio +async def test_intelligence_compiler_produces_report(monkeypatch: pytest.MonkeyPatch) -> None: + model = _DummyModel("## Executive Summary\nArbitrum is a leading L2 scaling solution.") + monkeypatch.setattr(intelligence_compiler, "get_chat_model", lambda: model) + + state = { + "input": "Research Arbitrum", + "plan": "1. News\n2. Profile\n3. Community", + "news": "Orbit chains launched", + "profile": "L2 rollup, $1.23, $4.5B mcap", + "community": "Strong community health", + } + result = await intelligence_compiler.intelligence_compiler_node(state) + + assert "Executive Summary" in result["report"] + assert "L2 scaling solution" in result["report"] diff --git a/examples/02-mcp-tool-integration/tests/unit/test_mcp_crypto_data.py b/examples/02-mcp-tool-integration/tests/unit/test_mcp_crypto_data.py new file mode 100644 index 0000000..8d165b7 --- /dev/null +++ b/examples/02-mcp-tool-integration/tests/unit/test_mcp_crypto_data.py @@ -0,0 +1,72 @@ +"""Unit tests for the crypto-data MCP server tools.""" + +from __future__ import annotations + +import json +from unittest.mock import AsyncMock, patch + +import pytest +from src.mcp_servers.crypto_data import get_coin_info, get_coin_price, search_coins + + +@pytest.mark.asyncio +async def test_search_coins_returns_formatted_results() -> None: + mock_response = { + "coins": [ + {"id": "arbitrum", "name": "Arbitrum", "symbol": "ARB", "market_cap_rank": 35}, + {"id": "ethereum", "name": "Ethereum", "symbol": "ETH", "market_cap_rank": 2}, + ] + } + with patch("src.mcp_servers.crypto_data._coingecko_get", new_callable=AsyncMock, return_value=mock_response): + result = await search_coins("arbitrum") + + data = json.loads(result) + assert len(data) == 2 + assert data[0]["id"] == "arbitrum" + assert data[0]["symbol"] == "ARB" + + +@pytest.mark.asyncio +async def test_get_coin_info_extracts_key_fields() -> None: + mock_response = { + "name": "Arbitrum", + "symbol": "arb", + "description": {"en": "Arbitrum is a Layer 2 optimistic rollup."}, + "categories": ["Layer 2", "Ethereum Ecosystem"], + "genesis_date": "2023-03-23", + "links": { + "homepage": ["https://arbitrum.io"], + "repos_url": {"github": ["https://github.com/OffchainLabs/nitro"]}, + "twitter_screen_name": "arbitrum", + }, + "community_data": {"twitter_followers": 500000}, + "developer_data": {"commits_4_weeks": 120, "forks": 350, "stars": 8000, "pull_request_contributors": 0}, + } + with patch("src.mcp_servers.crypto_data._coingecko_get", new_callable=AsyncMock, return_value=mock_response): + result = await get_coin_info("arbitrum") + + data = json.loads(result) + assert data["name"] == "Arbitrum" + assert "Layer 2" in data["categories"] + assert data["twitter"] == "arbitrum" + assert data["developer_data"]["commits_4_weeks"] == 120 + assert "pull_request_contributors" not in data["developer_data"] + + +@pytest.mark.asyncio +async def test_get_coin_price_returns_market_data() -> None: + mock_response = { + "arbitrum": { + "usd": 1.23, + "usd_market_cap": 4500000000, + "usd_24h_vol": 350000000, + "usd_24h_change": 5.67, + } + } + with patch("src.mcp_servers.crypto_data._coingecko_get", new_callable=AsyncMock, return_value=mock_response): + result = await get_coin_price("arbitrum") + + data = json.loads(result) + assert data["price"] == 1.23 + assert data["market_cap"] == 4500000000 + assert data["change_24h_pct"] == 5.67 diff --git a/examples/02-mcp-tool-integration/tests/unit/test_mcp_setup.py b/examples/02-mcp-tool-integration/tests/unit/test_mcp_setup.py new file mode 100644 index 0000000..20ed2ff --- /dev/null +++ b/examples/02-mcp-tool-integration/tests/unit/test_mcp_setup.py @@ -0,0 +1,83 @@ +"""Unit tests for MCP client lifecycle management.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from langchain_core.tools import BaseTool +from src import mcp_setup + + +@pytest.fixture(autouse=True) +def _reset_mcp_globals() -> None: + """Reset module-level state before each test.""" + mcp_setup._mcp_client = None + mcp_setup._mcp_tools = {} + + +def _make_fake_tool(name: str) -> MagicMock: + tool = MagicMock(spec=BaseTool) + tool.name = name + return tool + + +@pytest.mark.asyncio +async def test_init_mcp_enters_async_context_manager() -> None: + fake_tools: list[BaseTool] = [_make_fake_tool("search_coins"), _make_fake_tool("get_coin_price")] + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.get_tools = AsyncMock(return_value=fake_tools) + + with patch.object(mcp_setup, "MultiServerMCPClient", return_value=mock_client): + await mcp_setup.init_mcp() + + mock_client.__aenter__.assert_awaited_once() + mock_client.get_tools.assert_called_once() + assert mcp_setup._mcp_client is mock_client + assert set(mcp_setup._mcp_tools.keys()) == {"search_coins", "get_coin_price"} + + +@pytest.mark.asyncio +async def test_close_mcp_calls_aexit() -> None: + mock_client = AsyncMock() + mock_client.__aexit__ = AsyncMock(return_value=None) + mcp_setup._mcp_client = mock_client + + await mcp_setup.close_mcp() + + mock_client.__aexit__.assert_awaited_once_with(None, None, None) + assert mcp_setup._mcp_client is None + assert mcp_setup._mcp_tools == {} + + +@pytest.mark.asyncio +async def test_close_mcp_noop_when_no_client() -> None: + assert mcp_setup._mcp_client is None + await mcp_setup.close_mcp() + assert mcp_setup._mcp_client is None + + +def test_get_mcp_tool_returns_tool() -> None: + tool = _make_fake_tool("search_coins") + mcp_setup._mcp_tools = {"search_coins": tool} + + result = mcp_setup.get_mcp_tool("search_coins") + assert result is tool + + +def test_get_mcp_tool_raises_on_missing() -> None: + mcp_setup._mcp_tools = {"search_coins": _make_fake_tool("search_coins")} + + with pytest.raises(KeyError, match="get_coin_price"): + mcp_setup.get_mcp_tool("get_coin_price") + + +def test_get_all_mcp_tools_returns_list() -> None: + tools = [_make_fake_tool("a"), _make_fake_tool("b")] + mcp_setup._mcp_tools = {t.name: t for t in tools} + + result = mcp_setup.get_all_mcp_tools() + assert len(result) == 2 + assert set(t.name for t in result) == {"a", "b"} diff --git a/examples/02-mcp-tool-integration/tests/unit/test_state.py b/examples/02-mcp-tool-integration/tests/unit/test_state.py new file mode 100644 index 0000000..6c171b4 --- /dev/null +++ b/examples/02-mcp-tool-integration/tests/unit/test_state.py @@ -0,0 +1,23 @@ +"""Unit tests for Pattern 02 AgentState.""" + +from __future__ import annotations + +from src.agents.state import AgentState + + +def test_state_requires_input() -> None: + state: AgentState = {"input": "Research Arbitrum"} + assert state["input"] == "Research Arbitrum" + + +def test_state_all_fields() -> None: + state: AgentState = { + "input": "Research Arbitrum", + "plan": "1. News\n2. Team", + "news": "Partnership announced", + "profile": "L2 scaling solution", + "community": "Strong community health", + "report": "## Executive Summary", + } + assert state["profile"] == "L2 scaling solution" + assert state["community"] == "Strong community health" diff --git a/infra/docker/base/Dockerfile.agent b/infra/docker/base/Dockerfile.agent index cffd366..03e3417 100644 --- a/infra/docker/base/Dockerfile.agent +++ b/infra/docker/base/Dockerfile.agent @@ -4,7 +4,7 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/ WORKDIR /app -ARG EXAMPLE_DIR=examples/01-multi-agent-single-system +ARG EXAMPLE_DIR=examples/01-orchestrator-pipeline COPY pyproject.toml uv.lock ./ COPY libs/ libs/ @@ -18,7 +18,7 @@ FROM python:3.14-slim AS runtime WORKDIR /app -ARG EXAMPLE_DIR=examples/01-multi-agent-single-system +ARG EXAMPLE_DIR=examples/01-orchestrator-pipeline COPY --from=builder /app/.venv /app/.venv COPY libs/common/src/agent_common/ /app/agent_common/ diff --git a/libs/common/src/agent_common/config.py b/libs/common/src/agent_common/config.py index 4e8307d..90864a3 100644 --- a/libs/common/src/agent_common/config.py +++ b/libs/common/src/agent_common/config.py @@ -27,7 +27,7 @@ class Settings(BaseSettings): langsmith_project: str = "agent-patterns-lab" langsmith_tracing: bool = True - # Auth0 (Lesson 4+) + # Auth0 (Pattern 07+) auth0_domain: str = "" auth0_client_id: str = "" auth0_client_secret: str = "" diff --git a/libs/common/src/agent_common/llm.py b/libs/common/src/agent_common/llm.py index 6282d44..2ab0ef6 100644 --- a/libs/common/src/agent_common/llm.py +++ b/libs/common/src/agent_common/llm.py @@ -4,6 +4,8 @@ from typing import TYPE_CHECKING +from pydantic import SecretStr + from agent_common.config import get_settings if TYPE_CHECKING: @@ -28,7 +30,7 @@ def get_chat_model(provider: str | None = None, **kwargs: object) -> BaseChatMod return AzureChatOpenAI( azure_endpoint=settings.azure_openai_endpoint, - api_key=settings.azure_openai_api_key, + api_key=SecretStr(settings.azure_openai_api_key), azure_deployment=settings.azure_openai_deployment, api_version=settings.azure_openai_api_version, **kwargs, # type: ignore[arg-type] @@ -38,7 +40,7 @@ def get_chat_model(provider: str | None = None, **kwargs: object) -> BaseChatMod from langchain_anthropic import ChatAnthropic return ChatAnthropic( - api_key=settings.anthropic_api_key, + api_key=SecretStr(settings.anthropic_api_key), model_name=settings.anthropic_model, **kwargs, # type: ignore[arg-type] ) diff --git a/pyproject.toml b/pyproject.toml index bb67d91..6708f72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,12 +24,16 @@ warn_unused_configs = true [tool.pytest.ini_options] asyncio_mode = "auto" -testpaths = ["examples"] +testpaths = ["examples", "libs"] +addopts = "--tb=short -q --cov=examples --cov=libs --cov-report=term-missing --cov-report=xml --import-mode=importlib" [dependency-groups] dev = [ + "detect-secrets>=1.5.0", "mypy>=1.19.1", + "pre-commit>=4.5.1", "pytest>=9.0.2", "pytest-asyncio>=1.3.0", + "pytest-cov>=7.0.0", "ruff>=0.15.6", ] diff --git a/scripts/linting/run_mypy.py b/scripts/linting/run_mypy.py new file mode 100644 index 0000000..2c3e5d5 --- /dev/null +++ b/scripts/linting/run_mypy.py @@ -0,0 +1,62 @@ +"""Run mypy per-directory to avoid duplicate module names in monorepo. + +Each example has its own `src/` package, so mypy must check them separately +to prevent 'Duplicate module named src' errors. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +EXAMPLES_DIR = ROOT / "examples" +LIBS_DIR = ROOT / "libs" + +MYPY_BASE_ARGS: list[str] = [ + sys.executable, + "-m", + "mypy", + "--ignore-missing-imports", + "--exclude", + "(^|/)tests/", + "--disable-error-code=misc", + "--disable-error-code=unused-ignore", +] + + +def discover_check_dirs() -> list[Path]: + """Return directories that should be type-checked individually.""" + dirs: list[Path] = [] + if LIBS_DIR.exists(): + dirs.append(LIBS_DIR) + if EXAMPLES_DIR.exists(): + for example in sorted(EXAMPLES_DIR.iterdir()): + if example.is_dir() and (example / "src").is_dir(): + dirs.append(example) + return dirs + + +def main() -> int: + dirs = discover_check_dirs() + if not dirs: + print("No directories to type-check.") + return 0 + + worst_rc = 0 + for d in dirs: + rel = d.relative_to(ROOT) + print(f"mypy {rel}/") + result = subprocess.run( + [*MYPY_BASE_ARGS, str(d)], + cwd=ROOT, + check=False, + ) + worst_rc = max(worst_rc, result.returncode) + + return worst_rc + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/mcp-env.mjs b/scripts/mcp-env.mjs new file mode 100644 index 0000000..968a0bf --- /dev/null +++ b/scripts/mcp-env.mjs @@ -0,0 +1,38 @@ +import { spawn } from "node:child_process"; +import { readFileSync, existsSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const envFile = join(scriptDir, "..", ".env"); + +if (existsSync(envFile)) { + for (const line of readFileSync(envFile, "utf-8").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + const key = trimmed.slice(0, eq).trim(); + let val = trimmed.slice(eq + 1).trim(); + if ( + (val.startsWith('"') && val.endsWith('"')) || + (val.startsWith("'") && val.endsWith("'")) + ) { + val = val.slice(1, -1); + } + if (!process.env[key]) process.env[key] = val; + } +} + +const [command, ...args] = process.argv.slice(2); +const expanded = args.map((a) => + a.replace(/\$\{(.+?)\}/g, (_, v) => process.env[v] ?? ""), +); + +const child = spawn(command, expanded, { + stdio: "inherit", + env: process.env, + shell: true, +}); + +child.on("exit", (code) => process.exit(code ?? 1)); diff --git a/scripts/testing/run_test_suite.py b/scripts/testing/run_test_suite.py new file mode 100644 index 0000000..ae84ae7 --- /dev/null +++ b/scripts/testing/run_test_suite.py @@ -0,0 +1,99 @@ +"""Run unit, API, and end-to-end tests across all examples.""" + +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +EXAMPLES_DIR = ROOT / "examples" +LIBS_DIR = ROOT / "libs" + + +def _resolve_uv_binary() -> str: + """Resolve uv binary in a cross-platform friendly way.""" + uv_from_env = os.environ.get("UV_BIN") + if uv_from_env: + return uv_from_env + + uv_on_path = shutil.which("uv") + if uv_on_path: + return uv_on_path + + default_windows_uv = Path.home() / ".local" / "bin" / "uv.exe" + if default_windows_uv.exists(): + return str(default_windows_uv) + + msg = ( + "Could not find uv executable. Set UV_BIN or add uv to PATH. " + "See https://docs.astral.sh/uv/getting-started/installation/." + ) + raise FileNotFoundError(msg) + + +def discover_example_test_paths() -> list[Path]: + """Return test directories for all examples that contain tests.""" + if not EXAMPLES_DIR.exists(): + return [] + + paths: list[Path] = [] + for example_dir in sorted(EXAMPLES_DIR.iterdir()): + tests_dir = example_dir / "tests" + if example_dir.is_dir() and tests_dir.is_dir(): + paths.append(tests_dir) + return paths + + +def build_pytest_command(test_path: Path, *, include_coverage: bool) -> list[str]: + """Build pytest command for a single test directory.""" + uv_bin = _resolve_uv_binary() + command = [uv_bin, "run", "pytest", "--tb=short", "-q"] + + if not include_coverage: + command.append("--no-cov") + + command.append(str(test_path.relative_to(ROOT))) + return command + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run complete repository tests.") + parser.add_argument( + "--no-coverage", + action="store_true", + help="Disable coverage reporting.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + test_paths = discover_example_test_paths() + libs_tests = LIBS_DIR / "common" / "tests" + if libs_tests.is_dir(): + test_paths.append(libs_tests) + + if not test_paths: + msg = "No tests found in examples/*/tests or libs/common/tests." + raise RuntimeError(msg) + + worst_rc = 0 + for test_path in test_paths: + rel = test_path.relative_to(ROOT) + command = build_pytest_command(test_path, include_coverage=not args.no_coverage) + print(f"\npytest {rel}/") + result = subprocess.run(command, cwd=ROOT, check=False) + rc = result.returncode + if rc == 5: + print(f" (no tests collected in {rel}, skipping)") + continue + worst_rc = max(worst_rc, rc) + + return worst_rc + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/uv.lock b/uv.lock index 6ed7a7a..6b900df 100644 --- a/uv.lock +++ b/uv.lock @@ -6,7 +6,8 @@ requires-python = ">=3.14" members = [ "agent-common", "agent-patterns-lab", - "example-01-multi-agent-single-system", + "example-01-orchestrator-pipeline", + "example-02-mcp-tool-integration", ] [[package]] @@ -37,9 +38,12 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ + { name = "detect-secrets" }, { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "pytest-cov" }, { name = "ruff" }, ] @@ -47,12 +51,87 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "detect-secrets", specifier = ">=1.5.0" }, { name = "mypy", specifier = ">=1.19.1" }, + { name = "pre-commit", specifier = ">=4.5.1" }, { name = "pytest", specifier = ">=9.0.2" }, { name = "pytest-asyncio", specifier = ">=1.3.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, { name = "ruff", specifier = ">=0.15.6" }, ] +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -102,6 +181,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, ] +[[package]] +name = "attrs" +version = "25.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, +] + [[package]] name = "certifi" version = "2026.2.25" @@ -111,6 +199,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.5" @@ -157,6 +287,133 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "coverage" +version = "7.13.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, + { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, + { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, + { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, + { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, + { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, + { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, + { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, + { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, + { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, + { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, + { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, + { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, + { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, + { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, + { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, + { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, + { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, + { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +] + +[[package]] +name = "dataclasses-json" +version = "0.6.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "marshmallow" }, + { name = "typing-inspect" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, +] + +[[package]] +name = "detect-secrets" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/67/382a863fff94eae5a0cf05542179169a1c49a4c8784a9480621e2066ca7d/detect_secrets-1.5.0.tar.gz", hash = "sha256:6bb46dcc553c10df51475641bb30fd69d25645cc12339e46c824c1e0c388898a", size = 97351, upload-time = "2024-05-06T17:46:19.721Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/5e/4f5fe4b89fde1dc3ed0eb51bd4ce4c0bca406246673d370ea2ad0c58d747/detect_secrets-1.5.0-py3-none-any.whl", hash = "sha256:e24e7b9b5a35048c313e983f76c4bd09dad89f045ff059e354f9943bf45aa060", size = 120341, upload-time = "2024-05-06T17:46:16.628Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -176,14 +433,30 @@ wheels = [ ] [[package]] -name = "example-01-multi-agent-single-system" +name = "duckduckgo-search" +version = "8.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "lxml" }, + { name = "primp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/10/ef/07791a05751e6cc9de1dd49fb12730259ee109b18e6d097e25e6c32d5617/duckduckgo_search-8.1.1.tar.gz", hash = "sha256:9da91c9eb26a17e016ea1da26235d40404b46b0565ea86d75a9f78cc9441f935", size = 22868, upload-time = "2025-07-06T15:30:59.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/72/c027b3b488b1010cf71670032fcf7e681d44b81829d484bb04e31a949a8d/duckduckgo_search-8.1.1-py3-none-any.whl", hash = "sha256:f48adbb06626ee05918f7e0cef3a45639e9939805c4fc179e68c48a12f1b5062", size = 18932, upload-time = "2025-07-06T15:30:58.339Z" }, +] + +[[package]] +name = "example-01-orchestrator-pipeline" version = "0.1.0" -source = { virtual = "examples/01-multi-agent-single-system" } +source = { virtual = "examples/01-orchestrator-pipeline" } dependencies = [ { name = "agent-common" }, + { name = "duckduckgo-search" }, { name = "fastapi" }, { name = "httpx" }, { name = "langchain-anthropic" }, + { name = "langchain-community" }, { name = "langchain-core" }, { name = "langchain-openai" }, { name = "langgraph" }, @@ -195,9 +468,11 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "agent-common", editable = "libs/common" }, + { name = "duckduckgo-search", specifier = ">=8.0" }, { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, { name = "langchain-anthropic", specifier = ">=0.3" }, + { name = "langchain-community", specifier = ">=0.3" }, { name = "langchain-core", specifier = ">=0.3" }, { name = "langchain-openai", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.4" }, @@ -206,6 +481,45 @@ requires-dist = [ { name = "uvicorn", specifier = ">=0.34" }, ] +[[package]] +name = "example-02-mcp-tool-integration" +version = "0.1.0" +source = { virtual = "examples/02-mcp-tool-integration" } +dependencies = [ + { name = "agent-common" }, + { name = "duckduckgo-search" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "langchain-anthropic" }, + { name = "langchain-community" }, + { name = "langchain-core" }, + { name = "langchain-mcp-adapters" }, + { name = "langchain-openai" }, + { name = "langgraph" }, + { name = "langsmith" }, + { name = "mcp" }, + { name = "pydantic" }, + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [ + { name = "agent-common", editable = "libs/common" }, + { name = "duckduckgo-search", specifier = ">=8.0" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.28" }, + { name = "langchain-anthropic", specifier = ">=0.3" }, + { name = "langchain-community", specifier = ">=0.3" }, + { name = "langchain-core", specifier = ">=0.3" }, + { name = "langchain-mcp-adapters", specifier = ">=0.2" }, + { name = "langchain-openai", specifier = ">=0.3" }, + { name = "langgraph", specifier = ">=0.4" }, + { name = "langsmith", specifier = ">=0.3" }, + { name = "mcp", specifier = ">=1.0" }, + { name = "pydantic", specifier = ">=2.0" }, + { name = "uvicorn", specifier = ">=0.34" }, +] + [[package]] name = "fastapi" version = "0.135.1" @@ -222,6 +536,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, ] +[[package]] +name = "filelock" +version = "3.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/b8/00651a0f559862f3bb7d6f7477b192afe3f583cc5e26403b44e59a55ab34/filelock-3.25.2.tar.gz", hash = "sha256:b64ece2b38f4ca29dd3e810287aa8c48182bbecd1ae6e9ae126c9b35f1382694", size = 40480, upload-time = "2026-03-11T20:45:38.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/a5/842ae8f0c08b61d6484b52f99a03510a3a72d23141942d216ebe81fefbce/filelock-3.25.2-py3-none-any.whl", hash = "sha256:ca8afb0da15f229774c9ad1b455ed96e85a81373065fb10446672f64444ddf70", size = 26759, upload-time = "2026-03-11T20:45:37.437Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -259,6 +646,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "identify" +version = "2.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/84/376a3b96e5a8d33a7aa2c5b3b31a4b3c364117184bf0b17418055f6ace66/identify-2.6.17.tar.gz", hash = "sha256:f816b0b596b204c9fdf076ded172322f2723cf958d02f9c3587504834c8ff04d", size = 99579, upload-time = "2026-03-01T20:04:12.702Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/66/71c1227dff78aaeb942fed29dd5651f2aec166cc7c9aeea3e8b26a539b7d/identify-2.6.17-py2.py3-none-any.whl", hash = "sha256:be5f8412d5ed4b20f2bd41a65f920990bdccaa6a4a18a08f1eefdcd0bdd885f0", size = 99382, upload-time = "2026-03-01T20:04:11.439Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -331,6 +736,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/92/5e77f98553e9e75130c78900d000368476aed74276eb8ae8796f65f00918/jsonpointer-3.0.0-py2.py3-none-any.whl", hash = "sha256:13e088adc14fca8b6aa8177c044e12701e6ad4b28ff10e65f2267a90109c9942", size = 7595, upload-time = "2024-06-10T19:24:40.698Z" }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "langchain-anthropic" version = "1.3.5" @@ -345,6 +777,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/22/d967f55651f6a1d9157ecefd335f6c6232f4826b637a7e89c92495288524/langchain_anthropic-1.3.5-py3-none-any.whl", hash = "sha256:1666f83ddef74d9fa844ff3d1f9d23dd12004b0799d1ca1ff329cbaf3944d3c6", size = 48250, upload-time = "2026-03-14T03:12:32.165Z" }, ] +[[package]] +name = "langchain-classic" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "langchain-text-splitters" }, + { name = "langsmith" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/04/b01c09e37414bab9f209efa311502841a3c0de5bc6c35e729c8d8a9893c9/langchain_classic-1.0.3.tar.gz", hash = "sha256:168ef1dfbfb18cae5a9ff0accecc9413a5b5aa3464b53fa841561a3384b6324a", size = 10534933, upload-time = "2026-03-13T13:56:11.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/e6/cfdeedec0537ffbf5041773590d25beb7f2aa467cc6630e788c9c7c72c3e/langchain_classic-1.0.3-py3-none-any.whl", hash = "sha256:26df1ec9806b1fbff19d9085a747ea7d8d82d7e3fb1d25132859979de627ef79", size = 1041335, upload-time = "2026-03-13T13:56:09.677Z" }, +] + +[[package]] +name = "langchain-community" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "dataclasses-json" }, + { name = "httpx-sse" }, + { name = "langchain-classic" }, + { name = "langchain-core" }, + { name = "langsmith" }, + { name = "numpy" }, + { name = "pydantic-settings" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "sqlalchemy" }, + { name = "tenacity" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/97/a03585d42b9bdb6fbd935282d6e3348b10322a24e6ce12d0c99eb461d9af/langchain_community-0.4.1.tar.gz", hash = "sha256:f3b211832728ee89f169ddce8579b80a085222ddb4f4ed445a46e977d17b1e85", size = 33241144, upload-time = "2025-10-27T15:20:32.504Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f0/a4/c4fde67f193401512337456cabc2148f2c43316e445f5decd9f8806e2992/langchain_community-0.4.1-py3-none-any.whl", hash = "sha256:2135abb2c7748a35c84613108f7ebf30f8505b18c3c18305ffaecfc7651f6c6a", size = 2533285, upload-time = "2025-10-27T15:20:30.767Z" }, +] + [[package]] name = "langchain-core" version = "1.2.19" @@ -364,6 +837,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/cb/8704b2a22c0987627ed29464d23a45fb15e10a28fb482f4d84c3bddcbf27/langchain_core-1.2.19-py3-none-any.whl", hash = "sha256:6e74cb0fb443a8046ee298c05c99b67abe54cc57fcbc6d1cd3b0f2485ee47574", size = 503456, upload-time = "2026-03-13T13:44:53.241Z" }, ] +[[package]] +name = "langchain-mcp-adapters" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, + { name = "mcp" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/66/1cc7039e2daaddcdea9d8887851fe6eb67401925999b2aa394aa855c7132/langchain_mcp_adapters-0.2.2.tar.gz", hash = "sha256:12d39e91ae4389c54b61b221094e53850b6e152934d8bc10c80665d600e76530", size = 37942, upload-time = "2026-03-16T17:13:30.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/2f/15d5e6c1765d8404a9cce38d8c81d7b33fb3392f9db5b992c000dddbd2a3/langchain_mcp_adapters-0.2.2-py3-none-any.whl", hash = "sha256:d08e64954e86281002653071b7430e0377c9a577cb4ac3143abfeb3e24ef8797", size = 23288, upload-time = "2026-03-16T17:13:29.073Z" }, +] + [[package]] name = "langchain-openai" version = "1.1.11" @@ -378,6 +865,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f0/0f/e4cb42848c25f65969adfb500a06dea1a541831604250fd0d8aa6e54fef5/langchain_openai-1.1.11-py3-none-any.whl", hash = "sha256:a03596221405d38d6852fb865467cb0d9ff9e79f335905eb6a576e8c4874ac71", size = 87694, upload-time = "2026-03-09T23:02:35.651Z" }, ] +[[package]] +name = "langchain-text-splitters" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "langchain-core" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/38/14121ead61e0e75f79c3a35e5148ac7c2fe754a55f76eab3eed573269524/langchain_text_splitters-1.1.1.tar.gz", hash = "sha256:34861abe7c07d9e49d4dc852d0129e26b32738b60a74486853ec9b6d6a8e01d2", size = 279352, upload-time = "2026-02-18T23:02:42.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/66/d9e0c3b83b0ad75ee746c51ba347cacecb8d656b96e1d513f3e334d1ccab/langchain_text_splitters-1.1.1-py3-none-any.whl", hash = "sha256:5ed0d7bf314ba925041e7d7d17cd8b10f688300d5415fb26c29442f061e329dc", size = 35734, upload-time = "2026-02-18T23:02:41.913Z" }, +] + [[package]] name = "langgraph" version = "1.1.2" @@ -488,6 +987,132 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b2/c8/d148e041732d631fc76036f8b30fae4e77b027a1e95b7a84bb522481a940/librt-0.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:bf512a71a23504ed08103a13c941f763db13fb11177beb3d9244c98c29fb4a61", size = 48755, upload-time = "2026-02-17T16:12:47.943Z" }, ] +[[package]] +name = "lxml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/88/262177de60548e5a2bfc46ad28232c9e9cbde697bd94132aeb80364675cb/lxml-6.0.2.tar.gz", hash = "sha256:cd79f3367bd74b317dda655dc8fcfa304d9eb6e4fb06b7168c5cf27f96e0cd62", size = 4073426, upload-time = "2025-09-22T04:04:59.287Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/15/d4a377b385ab693ce97b472fe0c77c2b16ec79590e688b3ccc71fba19884/lxml-6.0.2-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:b0c732aa23de8f8aec23f4b580d1e52905ef468afb4abeafd3fec77042abb6fe", size = 8659801, upload-time = "2025-09-22T04:02:30.113Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e8/c128e37589463668794d503afaeb003987373c5f94d667124ffd8078bbd9/lxml-6.0.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4468e3b83e10e0317a89a33d28f7aeba1caa4d1a6fd457d115dd4ffe90c5931d", size = 4659403, upload-time = "2025-09-22T04:02:32.119Z" }, + { url = "https://files.pythonhosted.org/packages/00/ce/74903904339decdf7da7847bb5741fc98a5451b42fc419a86c0c13d26fe2/lxml-6.0.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:abd44571493973bad4598a3be7e1d807ed45aa2adaf7ab92ab7c62609569b17d", size = 4966974, upload-time = "2025-09-22T04:02:34.155Z" }, + { url = "https://files.pythonhosted.org/packages/1f/d3/131dec79ce61c5567fecf82515bd9bc36395df42501b50f7f7f3bd065df0/lxml-6.0.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:370cd78d5855cfbffd57c422851f7d3864e6ae72d0da615fca4dad8c45d375a5", size = 5102953, upload-time = "2025-09-22T04:02:36.054Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ea/a43ba9bb750d4ffdd885f2cd333572f5bb900cd2408b67fdda07e85978a0/lxml-6.0.2-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:901e3b4219fa04ef766885fb40fa516a71662a4c61b80c94d25336b4934b71c0", size = 5055054, upload-time = "2025-09-22T04:02:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/60/23/6885b451636ae286c34628f70a7ed1fcc759f8d9ad382d132e1c8d3d9bfd/lxml-6.0.2-cp314-cp314-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:a4bf42d2e4cf52c28cc1812d62426b9503cdb0c87a6de81442626aa7d69707ba", size = 5352421, upload-time = "2025-09-22T04:02:40.413Z" }, + { url = "https://files.pythonhosted.org/packages/48/5b/fc2ddfc94ddbe3eebb8e9af6e3fd65e2feba4967f6a4e9683875c394c2d8/lxml-6.0.2-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2c7fdaa4d7c3d886a42534adec7cfac73860b89b4e5298752f60aa5984641a0", size = 5673684, upload-time = "2025-09-22T04:02:42.288Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/47293c58cc91769130fbf85531280e8cc7868f7fbb6d92f4670071b9cb3e/lxml-6.0.2-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98a5e1660dc7de2200b00d53fa00bcd3c35a3608c305d45a7bbcaf29fa16e83d", size = 5252463, upload-time = "2025-09-22T04:02:44.165Z" }, + { url = "https://files.pythonhosted.org/packages/9b/da/ba6eceb830c762b48e711ded880d7e3e89fc6c7323e587c36540b6b23c6b/lxml-6.0.2-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:dc051506c30b609238d79eda75ee9cab3e520570ec8219844a72a46020901e37", size = 4698437, upload-time = "2025-09-22T04:02:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/a5/24/7be3f82cb7990b89118d944b619e53c656c97dc89c28cfb143fdb7cd6f4d/lxml-6.0.2-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8799481bbdd212470d17513a54d568f44416db01250f49449647b5ab5b5dccb9", size = 5269890, upload-time = "2025-09-22T04:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/dcfb9ea1e16c665efd7538fc5d5c34071276ce9220e234217682e7d2c4a5/lxml-6.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9261bb77c2dab42f3ecd9103951aeca2c40277701eb7e912c545c1b16e0e4917", size = 5097185, upload-time = "2025-09-22T04:02:50.746Z" }, + { url = "https://files.pythonhosted.org/packages/21/04/a60b0ff9314736316f28316b694bccbbabe100f8483ad83852d77fc7468e/lxml-6.0.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:65ac4a01aba353cfa6d5725b95d7aed6356ddc0a3cd734de00124d285b04b64f", size = 4745895, upload-time = "2025-09-22T04:02:52.968Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bd/7d54bd1846e5a310d9c715921c5faa71cf5c0853372adf78aee70c8d7aa2/lxml-6.0.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b22a07cbb82fea98f8a2fd814f3d1811ff9ed76d0fc6abc84eb21527596e7cc8", size = 5695246, upload-time = "2025-09-22T04:02:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/5643d6ab947bc371da21323acb2a6e603cedbe71cb4c99c8254289ab6f4e/lxml-6.0.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d759cdd7f3e055d6bc8d9bec3ad905227b2e4c785dc16c372eb5b5e83123f48a", size = 5260797, upload-time = "2025-09-22T04:02:57.058Z" }, + { url = "https://files.pythonhosted.org/packages/33/da/34c1ec4cff1eea7d0b4cd44af8411806ed943141804ac9c5d565302afb78/lxml-6.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:945da35a48d193d27c188037a05fec5492937f66fb1958c24fc761fb9d40d43c", size = 5277404, upload-time = "2025-09-22T04:02:58.966Z" }, + { url = "https://files.pythonhosted.org/packages/82/57/4eca3e31e54dc89e2c3507e1cd411074a17565fa5ffc437c4ae0a00d439e/lxml-6.0.2-cp314-cp314-win32.whl", hash = "sha256:be3aaa60da67e6153eb15715cc2e19091af5dc75faef8b8a585aea372507384b", size = 3670072, upload-time = "2025-09-22T04:03:38.05Z" }, + { url = "https://files.pythonhosted.org/packages/e3/e0/c96cf13eccd20c9421ba910304dae0f619724dcf1702864fd59dd386404d/lxml-6.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:fa25afbadead523f7001caf0c2382afd272c315a033a7b06336da2637d92d6ed", size = 4080617, upload-time = "2025-09-22T04:03:39.835Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5d/b3f03e22b3d38d6f188ef044900a9b29b2fe0aebb94625ce9fe244011d34/lxml-6.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:063eccf89df5b24e361b123e257e437f9e9878f425ee9aae3144c77faf6da6d8", size = 3754930, upload-time = "2025-09-22T04:03:41.565Z" }, + { url = "https://files.pythonhosted.org/packages/5e/5c/42c2c4c03554580708fc738d13414801f340c04c3eff90d8d2d227145275/lxml-6.0.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:6162a86d86893d63084faaf4ff937b3daea233e3682fb4474db07395794fa80d", size = 8910380, upload-time = "2025-09-22T04:03:01.645Z" }, + { url = "https://files.pythonhosted.org/packages/bf/4f/12df843e3e10d18d468a7557058f8d3733e8b6e12401f30b1ef29360740f/lxml-6.0.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:414aaa94e974e23a3e92e7ca5b97d10c0cf37b6481f50911032c69eeb3991bba", size = 4775632, upload-time = "2025-09-22T04:03:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0c/9dc31e6c2d0d418483cbcb469d1f5a582a1cd00a1f4081953d44051f3c50/lxml-6.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48461bd21625458dd01e14e2c38dd0aea69addc3c4f960c30d9f59d7f93be601", size = 4975171, upload-time = "2025-09-22T04:03:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2b/9b870c6ca24c841bdd887504808f0417aa9d8d564114689266f19ddf29c8/lxml-6.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:25fcc59afc57d527cfc78a58f40ab4c9b8fd096a9a3f964d2781ffb6eb33f4ed", size = 5110109, upload-time = "2025-09-22T04:03:07.452Z" }, + { url = "https://files.pythonhosted.org/packages/bf/0c/4f5f2a4dd319a178912751564471355d9019e220c20d7db3fb8307ed8582/lxml-6.0.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5179c60288204e6ddde3f774a93350177e08876eaf3ab78aa3a3649d43eb7d37", size = 5041061, upload-time = "2025-09-22T04:03:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/554eed290365267671fe001a20d72d14f468ae4e6acef1e179b039436967/lxml-6.0.2-cp314-cp314t-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:967aab75434de148ec80597b75062d8123cadf2943fb4281f385141e18b21338", size = 5306233, upload-time = "2025-09-22T04:03:11.651Z" }, + { url = "https://files.pythonhosted.org/packages/7a/31/1d748aa275e71802ad9722df32a7a35034246b42c0ecdd8235412c3396ef/lxml-6.0.2-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d100fcc8930d697c6561156c6810ab4a508fb264c8b6779e6e61e2ed5e7558f9", size = 5604739, upload-time = "2025-09-22T04:03:13.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/41/2c11916bcac09ed561adccacceaedd2bf0e0b25b297ea92aab99fd03d0fa/lxml-6.0.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ca59e7e13e5981175b8b3e4ab84d7da57993eeff53c07764dcebda0d0e64ecd", size = 5225119, upload-time = "2025-09-22T04:03:15.408Z" }, + { url = "https://files.pythonhosted.org/packages/99/05/4e5c2873d8f17aa018e6afde417c80cc5d0c33be4854cce3ef5670c49367/lxml-6.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:957448ac63a42e2e49531b9d6c0fa449a1970dbc32467aaad46f11545be9af1d", size = 4633665, upload-time = "2025-09-22T04:03:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/c9/dcc2da1bebd6275cdc723b515f93edf548b82f36a5458cca3578bc899332/lxml-6.0.2-cp314-cp314t-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b7fc49c37f1786284b12af63152fe1d0990722497e2d5817acfe7a877522f9a9", size = 5234997, upload-time = "2025-09-22T04:03:19.14Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e2/5172e4e7468afca64a37b81dba152fc5d90e30f9c83c7c3213d6a02a5ce4/lxml-6.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e19e0643cc936a22e837f79d01a550678da8377d7d801a14487c10c34ee49c7e", size = 5090957, upload-time = "2025-09-22T04:03:21.436Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b3/15461fd3e5cd4ddcb7938b87fc20b14ab113b92312fc97afe65cd7c85de1/lxml-6.0.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:1db01e5cf14345628e0cbe71067204db658e2fb8e51e7f33631f5f4735fefd8d", size = 4764372, upload-time = "2025-09-22T04:03:23.27Z" }, + { url = "https://files.pythonhosted.org/packages/05/33/f310b987c8bf9e61c4dd8e8035c416bd3230098f5e3cfa69fc4232de7059/lxml-6.0.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:875c6b5ab39ad5291588aed6925fac99d0097af0dd62f33c7b43736043d4a2ec", size = 5634653, upload-time = "2025-09-22T04:03:25.767Z" }, + { url = "https://files.pythonhosted.org/packages/70/ff/51c80e75e0bc9382158133bdcf4e339b5886c6ee2418b5199b3f1a61ed6d/lxml-6.0.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cdcbed9ad19da81c480dfd6dd161886db6096083c9938ead313d94b30aadf272", size = 5233795, upload-time = "2025-09-22T04:03:27.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/4d/4856e897df0d588789dd844dbed9d91782c4ef0b327f96ce53c807e13128/lxml-6.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:80dadc234ebc532e09be1975ff538d154a7fa61ea5031c03d25178855544728f", size = 5257023, upload-time = "2025-09-22T04:03:30.056Z" }, + { url = "https://files.pythonhosted.org/packages/0f/85/86766dfebfa87bea0ab78e9ff7a4b4b45225df4b4d3b8cc3c03c5cd68464/lxml-6.0.2-cp314-cp314t-win32.whl", hash = "sha256:da08e7bb297b04e893d91087df19638dc7a6bb858a954b0cc2b9f5053c922312", size = 3911420, upload-time = "2025-09-22T04:03:32.198Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1a/b248b355834c8e32614650b8008c69ffeb0ceb149c793961dd8c0b991bb3/lxml-6.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:252a22982dca42f6155125ac76d3432e548a7625d56f5a273ee78a5057216eca", size = 4406837, upload-time = "2025-09-22T04:03:34.027Z" }, + { url = "https://files.pythonhosted.org/packages/92/aa/df863bcc39c5e0946263454aba394de8a9084dbaff8ad143846b0d844739/lxml-6.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:bb4c1847b303835d89d785a18801a883436cdfd5dc3d62947f9c49e24f0f5a2c", size = 3822205, upload-time = "2025-09-22T04:03:36.249Z" }, +] + +[[package]] +name = "marshmallow" +version = "3.26.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + [[package]] name = "mypy" version = "1.19.1" @@ -518,6 +1143,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" }, + { url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" }, + { url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" }, + { url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" }, + { url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" }, + { url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" }, + { url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" }, + { url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" }, + { url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" }, + { url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" }, + { url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" }, + { url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" }, + { url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, +] + [[package]] name = "openai" version = "2.28.0" @@ -599,6 +1262,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, ] +[[package]] +name = "platformdirs" +version = "4.9.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -608,6 +1280,108 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "primp" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/0e/62ed44af95c66fd6fa8ad49c8bde815f64c7e976772d6979730be2b7cd97/primp-1.1.3.tar.gz", hash = "sha256:56adc3b8a5048cbd5f926b21fdff839195f3a9181512ca33f56ddc66f4c95897", size = 311356, upload-time = "2026-03-11T06:42:51.763Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/6b/36794b5758a0dd1251e67b6ab3ea946e53fa69745e0ecc29facc072ddf5b/primp-1.1.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:24383cfc267f620769be102b7fa4b64c7d47105f86bd21d047f1e07709e83c6e", size = 4000660, upload-time = "2026-03-11T06:42:58.092Z" }, + { url = "https://files.pythonhosted.org/packages/98/18/ebbe318a926d158c57f9e9cf49bbea70e8f0bd7f87e7675ed68e0d6ab433/primp-1.1.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:61bcb8c53b41e4bac43d04a1374b6ab7d8ded0f3517d32c5cdd5c30562756805", size = 3737318, upload-time = "2026-03-11T06:42:50.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4c/430c9154284b53b771e6713a18dec4ad0159e4a501a20b222d67c730ced9/primp-1.1.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0c6b9388578ee9d903f30549a792c5f391fdeb9d36b508da2ffb8e13c764954", size = 3881005, upload-time = "2026-03-11T06:43:12.894Z" }, + { url = "https://files.pythonhosted.org/packages/93/34/2466ef66386a1b50e6aaf7832f9f603628407bb33342378faf4b38c4aee8/primp-1.1.3-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:09a8bfa870c92c81d76611846ec53b2520845e3ec5f4139f47604986bcf4bc25", size = 3514480, upload-time = "2026-03-11T06:43:06.058Z" }, + { url = "https://files.pythonhosted.org/packages/ff/42/ca7a71df6493dd6c1971c0cc3b20b8125e2547eb3bf88b4429715cb6ed81/primp-1.1.3-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ac372cb9959fff690b255fad91c5b3bc948c14065da9fc00ad80d139651515af", size = 3734658, upload-time = "2026-03-11T06:43:47.486Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7c/0fb34db619e9935e11140929713c2c7b5323c1e8ba75cad6f0aade51c89d/primp-1.1.3-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3034672a007f04e12b8fe7814c97ea172e8b9c5d45bd7b00cf6e7334fdd4222a", size = 4011898, upload-time = "2026-03-11T06:43:41.121Z" }, + { url = "https://files.pythonhosted.org/packages/da/8b/afd1bd8b14f38d58c5ebd0d45fc6b74914956907aa4e981bb2e5231626d3/primp-1.1.3-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a07d5b7d7278dc63452a59f3bf851dc4d1f8ddc2aada7844cbdb68002256e2f4", size = 3910728, upload-time = "2026-03-11T06:43:01.819Z" }, + { url = "https://files.pythonhosted.org/packages/32/9e/1ec3a9678efcbb51e50d7b4886d9195f956c9fd7f4efcff13ccb152248b0/primp-1.1.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08eec2f58abbcc1060032a2af81dabacec87a580a364a75862039f7422ac82e6", size = 4114189, upload-time = "2026-03-11T06:42:47.639Z" }, + { url = "https://files.pythonhosted.org/packages/28/d9/76de611027c0688be188d5a833be45b1e36d9c0c98baefab27bf6336ab9d/primp-1.1.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9716d4cd36db2c175443fe1bbd54045a944fc9c49d01a385af8ada1fe9c948df", size = 4061973, upload-time = "2026-03-11T06:43:37.301Z" }, + { url = "https://files.pythonhosted.org/packages/37/3b/a30a5ea366705d0ece265b12ad089793d644bd5730b18201e3a0a7fa7b5f/primp-1.1.3-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e19daca65dc6df369c33e711fa481ad2afe5d26c5bde926c069b3ab067c4fd45", size = 3747920, upload-time = "2026-03-11T06:43:10.403Z" }, + { url = "https://files.pythonhosted.org/packages/df/46/e3c323221c371cdfe6c2ed971f7a70e3b69f30b561977715c55230bd5fda/primp-1.1.3-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:ee357537712aa486364b0194cf403c5f9eaaa1354e23e9ac8322a22003f31e6b", size = 3861184, upload-time = "2026-03-11T06:43:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7f/babaf00753daad7d80061003d7ae1bdfca64ea94c181cdea8d25c8a7226a/primp-1.1.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:06c53e77ebf6ac00633bc09e7e5a6d1a994592729d399ca8f065451a2574b92e", size = 4364610, upload-time = "2026-03-11T06:42:56.223Z" }, + { url = "https://files.pythonhosted.org/packages/03/48/c7bca8045c681f5f60972c180d2a20582c7a0857b3b07b12e0a0ee062ac4/primp-1.1.3-cp310-abi3-win32.whl", hash = "sha256:4b1ea3693c118bf04a6e05286f0a73637cf6fe5c9fd77fa1e29a01f190adf512", size = 3265160, upload-time = "2026-03-11T06:43:43.774Z" }, + { url = "https://files.pythonhosted.org/packages/45/3e/4a4b8a0f6f15734cded91e85439e68912b2bb8eafe7132420c13c2db8340/primp-1.1.3-cp310-abi3-win_amd64.whl", hash = "sha256:5ea386a4c8c4d8c1021d17182f4ee24dbb6f17c107c4e9ee5500b6372cf08f32", size = 3603953, upload-time = "2026-03-11T06:43:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/70/46/1baf13a7f5fbed6052deb3e4822c69441a8d0fd990fe2a50e4cec802130b/primp-1.1.3-cp310-abi3-win_arm64.whl", hash = "sha256:63c7b1a1ccbcd07213f438375df186f807cdc5214bc2debb055737db9b5078de", size = 3619917, upload-time = "2026-03-11T06:42:44.76Z" }, + { url = "https://files.pythonhosted.org/packages/be/0c/a73cbe13f075e7ceaa5172b44ebc6f423713c6b4efe168114993a1710b26/primp-1.1.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4b3d52f3233134584ef527e7e52f1b371a964ade1df0461f8187100e41d7fa84", size = 3987141, upload-time = "2026-03-11T06:43:24.904Z" }, + { url = "https://files.pythonhosted.org/packages/49/56/b70d7991fb1e07af53706b1f69f78a0b440a7b4b2a2999c44ab44afef1e7/primp-1.1.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b3d947e2c1d15147e8f4736d027b9f3bef518d67da859ead1c54e028ff491bbb", size = 3735665, upload-time = "2026-03-11T06:43:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/31/82/69efc663341c2bab55659ed221903a090e5c80255c2de2acc70f3726a3fc/primp-1.1.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3ed2fee7d4758f6bb873b19a6759f54e0bc453213dad5ba7e52de7582921079", size = 3873695, upload-time = "2026-03-11T06:43:15.396Z" }, + { url = "https://files.pythonhosted.org/packages/07/7e/6b360742019ef8fb4ea036a420eb21b0a58d380ca09c68b075fc103cc043/primp-1.1.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5aa717f256af9e4391fb1c4dc946d99d04652b4c57dad20c3947e839ab26769", size = 3512644, upload-time = "2026-03-11T06:43:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/03/46/51d2ada6d5b53b8496eddf2c80392deab13698987412d0234f88e72390c1/primp-1.1.3-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:17f37fcacd97540f68b06f2b468b111ca7f2b142c48370db7344b522274fc0d6", size = 3733114, upload-time = "2026-03-11T06:43:22.838Z" }, + { url = "https://files.pythonhosted.org/packages/45/f5/5f5f5f4bef7e247ec3543e2fbdb670d8db8753a7693baf9c8b9fcf52cd43/primp-1.1.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d5f010d0b8ba111dd9a66f814c2cd56332e047c98f45d7714ffbf2b1cec5b073", size = 4005664, upload-time = "2026-03-11T06:43:20.824Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bf/99cf4a5f179b3f13b0c2ba4d3ae8f8af19f0084308e76cb79a0cee03c31b/primp-1.1.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e1e431915e4a7094d589213fc14e955243d93751031d889f4b359fa8ed54298", size = 3895746, upload-time = "2026-03-11T06:43:35.376Z" }, + { url = "https://files.pythonhosted.org/packages/c3/75/4c625e1cab37585365b0856ca44f31ad598e92a847d23561f454b7f36fca/primp-1.1.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaffa22dae2f193d899d9f68cca109ea5d16cdf4c901c20cec186de89e7d5db4", size = 4109815, upload-time = "2026-03-11T06:43:04.059Z" }, + { url = "https://files.pythonhosted.org/packages/49/72/6197ea78779d359f307be1acc64659896fc960ed91c0bdc6e6e698e423e6/primp-1.1.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f93bee50990884621ef482e8434e87f9fbb4eca6f4d47973c44c5d6393c35679", size = 4050839, upload-time = "2026-03-11T06:43:18.296Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b2/cdd565b28bcf7ce555f4decdf89dafd16db8ed3ba8661890d3b9337abe45/primp-1.1.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:399dfb9ad01c3612c9e510a7034ac925af5524cade0961d8a019dedd90a46474", size = 3748397, upload-time = "2026-03-11T06:43:27.347Z" }, + { url = "https://files.pythonhosted.org/packages/62/6e/def3a90821b52589dbe1f57477c2c89bde7a5b26a7c166d7751930c06f98/primp-1.1.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:78ce595bbb9f339e83975efa9db2a81128842fad1a2fdafb78d72fcdc59590fc", size = 3861261, upload-time = "2026-03-11T06:43:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/10/7d/3e610614d6a426502cfc6eccea21ef4557b39177d365df393c994945ca43/primp-1.1.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d709bdf520aa9401c0592b642730b3477c828629f01d2550977b77135b34e8d", size = 4358608, upload-time = "2026-03-11T06:43:45.606Z" }, + { url = "https://files.pythonhosted.org/packages/91/50/eb190cefe5eb05896825a5b3365d5650b9327161329cd1df4f7351b66ba9/primp-1.1.3-cp314-cp314t-win32.whl", hash = "sha256:6fe893eb87156dfb146dd666c7c8754670de82e38af0a27d82a47b7461ec2eea", size = 3259903, upload-time = "2026-03-11T06:42:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a8/9e8534bc6d729a667f79b249fcdbf2230b0eb41214e277998cd6be900498/primp-1.1.3-cp314-cp314t-win_amd64.whl", hash = "sha256:ced76ef6669f31dc4af25e81e87914310645bcfc0892036bde084dafd6d00c3c", size = 3602569, upload-time = "2026-03-11T06:42:53.955Z" }, + { url = "https://files.pythonhosted.org/packages/9c/92/e18be996a01c7fd0e7dd7d198edefe42813cdfe1637bbbc80370ce656f62/primp-1.1.3-cp314-cp314t-win_arm64.whl", hash = "sha256:efadef0dfd10e733a254a949abf9ed05c668c28a68aa6513d811c0c6acd54cdb", size = 3611571, upload-time = "2026-03-11T06:43:31.249Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -685,6 +1459,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -713,6 +1501,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, ] +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945, upload-time = "2026-03-10T15:08:15.038Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485, upload-time = "2026-03-10T15:08:13.06Z" }, +] + [[package]] name = "python-dotenv" version = "1.2.2" @@ -722,6 +1537,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pywin32" +version = "311" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -748,6 +1582,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "regex" version = "2026.2.28" @@ -815,6 +1662,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, +] + [[package]] name = "ruff" version = "0.15.6" @@ -849,6 +1733,45 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.48" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1f/73/b4a9737255583b5fa858e0bb8e116eb94b88c910164ed2ed719147bde3de/sqlalchemy-2.0.48.tar.gz", hash = "sha256:5ca74f37f3369b45e1f6b7b06afb182af1fd5dde009e4ffd831830d98cbe5fe7", size = 9886075, upload-time = "2026-03-02T15:28:51.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/b3/f437eaa1cf028bb3c927172c7272366393e73ccd104dcf5b6963f4ab5318/sqlalchemy-2.0.48-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e2d0d88686e3d35a76f3e15a34e8c12d73fc94c1dea1cd55782e695cc14086dd", size = 2154401, upload-time = "2026-03-02T15:49:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/6c/1c/b3abdf0f402aa3f60f0df6ea53d92a162b458fca2321d8f1f00278506402/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49b7bddc1eebf011ea5ab722fdbe67a401caa34a350d278cc7733c0e88fecb1f", size = 3274528, upload-time = "2026-03-02T15:50:41.489Z" }, + { url = "https://files.pythonhosted.org/packages/f2/5e/327428a034407651a048f5e624361adf3f9fbac9d0fa98e981e9c6ff2f5e/sqlalchemy-2.0.48-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:426c5ca86415d9b8945c7073597e10de9644802e2ff502b8e1f11a7a2642856b", size = 3279523, upload-time = "2026-03-02T15:53:32.962Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ca/ece73c81a918add0965b76b868b7b5359e068380b90ef1656ee995940c02/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:288937433bd44e3990e7da2402fabc44a3c6c25d3704da066b85b89a85474ae0", size = 3224312, upload-time = "2026-03-02T15:50:42.996Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/fbaf1ae91fa4ee43f4fe79661cead6358644824419c26adb004941bdce7c/sqlalchemy-2.0.48-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8183dc57ae7d9edc1346e007e840a9f3d6aa7b7f165203a99e16f447150140d2", size = 3246304, upload-time = "2026-03-02T15:53:34.937Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5fb0deb13930b4f2f698c5541ae076c18981173e27dd00376dbaea7a9c82/sqlalchemy-2.0.48-cp314-cp314-win32.whl", hash = "sha256:1182437cb2d97988cfea04cf6cdc0b0bb9c74f4d56ec3d08b81e23d621a28cc6", size = 2116565, upload-time = "2026-03-02T15:54:38.321Z" }, + { url = "https://files.pythonhosted.org/packages/95/7e/e83615cb63f80047f18e61e31e8e32257d39458426c23006deeaf48f463b/sqlalchemy-2.0.48-cp314-cp314-win_amd64.whl", hash = "sha256:144921da96c08feb9e2b052c5c5c1d0d151a292c6135623c6b2c041f2a45f9e0", size = 2142205, upload-time = "2026-03-02T15:54:39.831Z" }, + { url = "https://files.pythonhosted.org/packages/83/e3/69d8711b3f2c5135e9cde5f063bc1605860f0b2c53086d40c04017eb1f77/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5aee45fd2c6c0f2b9cdddf48c48535e7471e42d6fb81adfde801da0bd5b93241", size = 3563519, upload-time = "2026-03-02T15:57:52.387Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4f/a7cce98facca73c149ea4578981594aaa5fd841e956834931de503359336/sqlalchemy-2.0.48-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7cddca31edf8b0653090cbb54562ca027c421c58ddde2c0685f49ff56a1690e0", size = 3528611, upload-time = "2026-03-02T16:04:42.097Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7d/5936c7a03a0b0cb0fa0cc425998821c6029756b0855a8f7ee70fba1de955/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7a936f1bb23d370b7c8cc079d5fce4c7d18da87a33c6744e51a93b0f9e97e9b3", size = 3472326, upload-time = "2026-03-02T15:57:54.423Z" }, + { url = "https://files.pythonhosted.org/packages/f4/33/cea7dfc31b52904efe3dcdc169eb4514078887dff1f5ae28a7f4c5d54b3c/sqlalchemy-2.0.48-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e004aa9248e8cb0a5f9b96d003ca7c1c0a5da8decd1066e7b53f59eb8ce7c62b", size = 3478453, upload-time = "2026-03-02T16:04:44.584Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/32107c4d13be077a9cae61e9ae49966a35dc4bf442a8852dd871db31f62e/sqlalchemy-2.0.48-cp314-cp314t-win32.whl", hash = "sha256:b8438ec5594980d405251451c5b7ea9aa58dda38eb7ac35fb7e4c696712ee24f", size = 2147209, upload-time = "2026-03-02T15:52:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d7/1e073da7a4bc645eb83c76067284a0374e643bc4be57f14cc6414656f92c/sqlalchemy-2.0.48-cp314-cp314t-win_amd64.whl", hash = "sha256:d854b3970067297f3a7fbd7a4683587134aa9b3877ee15aa29eea478dc68f933", size = 2182198, upload-time = "2026-03-02T15:52:55.606Z" }, + { url = "https://files.pythonhosted.org/packages/46/2c/9664130905f03db57961b8980b05cab624afd114bf2be2576628a9f22da4/sqlalchemy-2.0.48-py3-none-any.whl", hash = "sha256:a66fe406437dd65cacd96a72689a3aaaecaebbcd62d81c5ac1c0fdbeac835096", size = 1940202, upload-time = "2026-03-02T15:52:43.285Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/2f/9223c24f568bb7a0c03d751e609844dce0968f13b39a3f73fbb3a96cd27a/sse_starlette-3.3.3.tar.gz", hash = "sha256:72a95d7575fd5129bd0ae15275ac6432bb35ac542fdebb82889c24bb9f3f4049", size = 32420, upload-time = "2026-03-17T20:05:55.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/e2/b8cff57a67dddf9a464d7e943218e031617fb3ddc133aeeb0602ff5f6c85/sse_starlette-3.3.3-py3-none-any.whl", hash = "sha256:c5abb5082a1cc1c6294d89c5290c46b5f67808cfdb612b7ec27e8ba061c22e8d", size = 14329, upload-time = "2026-03-17T20:05:54.35Z" }, +] + [[package]] name = "starlette" version = "0.52.1" @@ -917,6 +1840,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspect" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" }, +] + [[package]] name = "typing-inspection" version = "0.4.2" @@ -973,6 +1909,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, ] +[[package]] +name = "virtualenv" +version = "21.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/92/58199fe10049f9703c2666e809c4f686c54ef0a68b0f6afccf518c0b1eb9/virtualenv-21.2.0.tar.gz", hash = "sha256:1720dc3a62ef5b443092e3f499228599045d7fea4c79199770499df8becf9098", size = 5840618, upload-time = "2026-03-09T17:24:38.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/59/7d02447a55b2e55755011a647479041bc92a82e143f96a8195cb33bd0a1c/virtualenv-21.2.0-py3-none-any.whl", hash = "sha256:1bd755b504931164a5a496d217c014d098426cddc79363ad66ac78125f9d908f", size = 5825084, upload-time = "2026-03-09T17:24:35.378Z" }, +] + [[package]] name = "xxhash" version = "3.6.0" @@ -1011,6 +1962,56 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0f/c9/7243eb3f9eaabd1a88a5a5acadf06df2d83b100c62684b7425c6a11bcaa8/xxhash-3.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bb79b1e63f6fd84ec778a4b1916dfe0a7c3fdb986c06addd5db3a0d413819d95", size = 28898, upload-time = "2025-10-02T14:36:17.843Z" }, ] +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] + [[package]] name = "zstandard" version = "0.25.0"