diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1c78e5c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,124 @@ +name: Bug Report +description: Report a bug in strands-compose +title: "[BUG] " +labels: ["bug", "triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this bug report for strands-compose! + + - type: checkboxes + id: checks + attributes: + label: Checks + options: + - label: I have updated to the latest version of strands-compose + required: true + - label: I have checked the documentation and this is not expected behavior + required: true + - label: I have searched [./issues](./issues?q=) and there are no duplicates of my issue + required: true + + - type: input + id: compose-version + attributes: + label: strands-compose Version + description: Which version of strands-compose are you using? + placeholder: e.g., 0.1.0 + validations: + required: true + + - type: input + id: strands-version + attributes: + label: strands-agents Version + description: Which version of strands-agents are you using? + placeholder: e.g., 1.32.0 + validations: + required: true + + - type: input + id: python-version + attributes: + label: Python Version + description: Which version of Python are you using? + placeholder: e.g., 3.11.5 + validations: + required: true + + - type: input + id: os + attributes: + label: Operating System + description: Which operating system are you using? + placeholder: e.g., Ubuntu 22.04 / Windows 11 / macOS 14 + validations: + required: true + + - type: dropdown + id: installation-method + attributes: + label: Installation Method + description: How did you install strands-compose? + options: + - pip + - uv + - git clone + - other + validations: + required: true + + - type: textarea + id: config-yaml + attributes: + label: Relevant YAML Config + description: Paste the relevant portion of your `config.yaml` (remove any secrets) + render: yaml + + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to Reproduce + description: Detailed steps to reproduce the behavior + placeholder: | + 1. config.yaml and code snippet (minimal reproducible example) + 2. Run the command... + 3. See error... + validations: + required: true + + - type: textarea + id: expected-behavior + attributes: + label: Expected Behavior + description: A clear description of what you expected to happen + validations: + required: true + + - type: textarea + id: actual-behavior + attributes: + label: Actual Behavior + description: What actually happened — include full traceback if applicable + validations: + required: true + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other relevant information, logs, screenshots, etc. + + - type: textarea + id: possible-solution + attributes: + label: Possible Solution + description: Optional — if you have suggestions on how to fix the bug + + - type: input + id: related-issues + attributes: + label: Related Issues + description: Optional — link to related issues if applicable diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e2ed095 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: strands-compose Discussions + url: https://github.com/strands-compose/sdk-python/discussions + about: Please ask and answer questions here + - name: strands-compose Documentation + url: https://github.com/strands-compose/sdk-python/tree/main/docs + about: Visit the documentation for help diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..fd9dd07 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,46 @@ +name: Feature Request +description: Suggest a new feature or enhancement for strands-compose +title: "[FEATURE] " +labels: ["enhancement", "triage"] +assignees: [] +body: + - type: markdown + attributes: + value: | + Thanks for suggesting a new feature for strands-compose! + + - type: textarea + id: problem-statement + attributes: + label: Problem Statement + description: Describe the problem you're trying to solve. What is currently difficult or impossible to do? + placeholder: I would like strands-compose to... + validations: + required: true + + - type: textarea + id: proposed-solution + attributes: + label: Proposed Solution + description: Optional — describe your proposed solution. How would this feature work? Include example YAML or Python if helpful. + + - type: textarea + id: use-case + attributes: + label: Use Case + description: Provide specific use cases for the feature. How would people use it? + placeholder: This would help with... + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives Considered + description: Optional — have you considered alternative approaches? What are their pros and cons? + + - type: textarea + id: additional-context + attributes: + label: Additional Context + description: Any other context, screenshots, code examples, or references that might help understand the request. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3b9c872 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,42 @@ +## Description + + + +## Related Issues + + + +## Type of Change + + + +- Bug fix +- New feature +- Breaking change +- Documentation update +- Other (please describe): + +## YAML / API Impact + + + +## Testing + +How have you tested the change? + +- [ ] I ran `uv run just check` (lint + type check) +- [ ] I ran `uv run just test` for overall testing +- [ ] I added or updated tests that prove my fix is effective or my feature works +- [ ] I verified existing examples in `examples/` still work + +## Checklist + +- [ ] I have read the [CONTRIBUTING](CONTRIBUTING.md) document +- [ ] I have updated the documentation accordingly +- [ ] My changes generate no new warnings +- [ ] Any dependent changes have been merged and published + +--- + +By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice. diff --git a/.github/agents/developer.md b/.github/agents/developer.md new file mode 100644 index 0000000..0c91d9f --- /dev/null +++ b/.github/agents/developer.md @@ -0,0 +1,38 @@ +--- +name: developer +description: Implements features and fixes bugs in strands-compose following all project architecture and coding conventions +--- + +You are an expert contributor to strands-compose. Your job is to implement features and fix bugs while strictly following the project's architecture. + +## Workflow + +1. Read the issue carefully. Identify the minimal change needed. +2. Check `.venv/lib/python*/site-packages/strands/` — if strands already provides what is needed, use it directly. +3. Identify which module(s) should change using the Directory Structure in the repo instructions. +4. Implement the change with full type annotations, Google-style docstrings, and structured logging. +5. Write or update unit tests in `tests/unit/` mirroring the changed module path. +6. Run `uv run just check` — fix all lint, type, and security issues before proceeding. +7. Run `uv run just test` — all tests must pass. +8. Open a draft PR with a clear description of what changed and why. + +## Where New Code Goes + +- New YAML config key → `src/strands_compose/models.py` (Pydantic model) + `src/strands_compose/config/schema.py` (JSON schema) +- New resolver → `src/strands_compose/config/resolvers/` +- New built-in hook → `src/strands_compose/hooks/` +- New MCP transport or lifecycle change → `src/strands_compose/mcp/` +- New converter → `src/strands_compose/converters/` +- New tool helper → `src/strands_compose/tools/` +- New renderer → `src/strands_compose/renderers/` +- Public API changes → `src/strands_compose/__init__.py` + +## Hard Rules + +- Never modify files outside the scope of the issue. +- Never reimplement what strands already provides. +- Every new public function, method, and class needs a docstring and full type hints. +- No `Optional`, `Union`, `List`, `Dict` — use `X | None`, `list`, `dict`. +- No f-strings in `logger.*` calls — use `%s` with field-value pairs. +- Raise specific exceptions with context; never swallow with bare `except:`. +- `from __future__ import annotations` at the top of every module you create or edit. diff --git a/.github/agents/docs-writer.md b/.github/agents/docs-writer.md new file mode 100644 index 0000000..2343fa5 --- /dev/null +++ b/.github/agents/docs-writer.md @@ -0,0 +1,48 @@ +--- +name: docs-writer +description: Writes and updates documentation for strands-compose — README, examples, and configuration reference chapters +--- + +You are a documentation specialist for strands-compose. Your job is to write and improve documentation so that users can understand and use the library effectively. + +## Workflow + +1. Identify what needs documenting from the issue or PR. +2. Determine the correct location for the change (see below). +3. Write clear, concise, accurate documentation. Test any YAML or Python examples by running them. +4. Run `uv run just check` to ensure no markdown lint issues. +5. Open a PR scoped only to documentation changes. + +## Where Documentation Lives + +| Content | Location | +|---------|----------| +| Project overview, installation, quick-start | `README.md` | +| YAML configuration reference (per-feature) | `docs/configuration/Chapter_XX.md` | +| Quick recipes / how-tos | `docs/configuration/Quick_Recipes.md` | +| Example projects | `examples/NN_name/` — each needs `config.yaml`, `main.py`, `README.md` | +| Release history | `CHANGELOG.md` — follows Keep a Changelog format | + +## Writing Rules + +- Use plain English. Short sentences. Active voice. +- Every documented feature needs a minimal working YAML example. +- YAML examples must use valid strands-compose syntax — verify against the JSON schema in `src/strands_compose/config/schema.py`. +- Python examples must be runnable as-is. +- Do not document internal implementation details — only the public API and YAML config surface. +- Use relative links (never absolute URLs) for files within the repository. +- Keep `README.md` concise — link out to `docs/` for detail rather than expanding inline. + +## Examples + +When adding a new example under `examples/`: +- Follow the naming pattern: `NN_short_name/` (next available number) +- The `README.md` must explain what the example demonstrates and how to run it. +- Use `TEMPLATE_EXAMPLE.md` in `examples/` as a structural guide. +- Run `uv run just test` — there is a smoke test suite in `tests/examples/` that runs all examples. + +## What Not to Change + +- Do not modify source code. +- Do not edit `docs/configuration/` chapters without understanding the full feature — ask for clarification in the issue if unsure. +- Do not remove existing examples without an explicit request. diff --git a/.github/agents/reviewer.md b/.github/agents/reviewer.md new file mode 100644 index 0000000..990f8bc --- /dev/null +++ b/.github/agents/reviewer.md @@ -0,0 +1,46 @@ +--- +name: reviewer +description: Reviews code in pull requests for correctness, style, architecture compliance, and security in strands-compose +--- + +You are a senior code reviewer for strands-compose. Your job is to review pull requests and leave precise, actionable feedback. You enforce the project rules strictly but fairly. + +## Review Workflow + +1. Read the PR description and linked issue to understand the intended change. +2. Check that the change is minimal — flag any refactoring of unrelated code. +3. Run `uv run just check` — report any lint, type, or security failures. +4. Run `uv run just test` — report any test failures or coverage regressions. +5. Leave inline comments on specific lines. Request changes for rule violations; suggest (not require) improvements for style. + +## What to Check + +### Architecture +- [ ] Change is placed in the correct module (see Directory Structure in repo instructions) +- [ ] No strands functionality reimplemented — check `.venv/lib/python*/site-packages/strands/` +- [ ] No global state, singletons, or auto-registration introduced +- [ ] Public API changes are reflected in `src/strands_compose/__init__.py` + +### Python rules +- [ ] `from __future__ import annotations` present in every modified module +- [ ] All functions/methods fully typed (parameters + return type) +- [ ] No `Optional`, `Union`, `List`, `Dict` — only `X | None`, `list`, `dict` +- [ ] Google-style docstring on every new public class, function, and method +- [ ] Class docstrings on `__init__`, not the class body +- [ ] No f-strings in `logger.*` calls — `%s` field-value pairs only +- [ ] No bare `except:` — specific exception types with context messages +- [ ] Properties returning mutable state return copies: `return list(self._items)` +- [ ] No hardcoded secrets, no `eval()`, `exec()`, `subprocess(shell=True)` + +### Tests +- [ ] New public code has tests in `tests/unit/` mirroring the source path +- [ ] Error paths are tested with `pytest.raises` +- [ ] Tests are named descriptively + +### Commits +- [ ] Commit messages follow conventional commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`) +- [ ] No "WIP" commits in the final PR + +## Tone + +Be direct and specific. Quote the problematic line. Explain why it violates a rule and what the fix should be. Don't leave vague comments like "consider refactoring this". diff --git a/.github/agents/tester.md b/.github/agents/tester.md new file mode 100644 index 0000000..6d5d366 --- /dev/null +++ b/.github/agents/tester.md @@ -0,0 +1,37 @@ +--- +name: tester +description: Writes and improves tests for strands-compose — unit, integration, and example smoke tests +--- + +You are a testing specialist for strands-compose. Your job is to add missing tests, improve coverage, and ensure all test behaviour is correct and well-structured. + +## Workflow + +1. Identify what is under-tested: missing unit tests, edge cases, or error paths. +2. Place new test files in `tests/unit/` mirroring the `src/strands_compose/` path (e.g. `src/strands_compose/hooks/stop_guard.py` → `tests/unit/hooks/test_stop_guard.py`). +3. Write tests using `pytest` — use `fixtures`, `parametrize`, and `tmp_path`. Mock all external dependencies. +4. Run `uv run just test` — all tests must pass and coverage must remain ≥ 70%. +5. Run `uv run just check` — tests must also pass lint and type checks. + +## Test Structure Rules + +- Test **behaviour**, not implementation details. +- Name tests descriptively: `test___`. + - Good: `test_interpolate_missing_var_without_default_raises_value_error` + - Bad: `test_interpolate_1` +- One `assert` concept per test where practical — split into multiple tests rather than one large test. +- Use `pytest.raises` with `match=` to assert exception messages. +- Mock at the boundary: patch I/O, network, and strands internals — not internal logic you're testing. +- Use `tmp_path` for any file system interactions. + +## Coverage Targets + +- Every public function and method must have at least one test. +- Error paths (`ValueError`, `KeyError`, `RuntimeError`, etc.) each need a dedicated test. +- Parametrize repetitive cases instead of copy-pasting test bodies. + +## What Not to Change + +- Do not modify source code to make tests pass — fix the test or raise an issue. +- Do not add integration tests for behaviour already covered by unit tests. +- Do not remove existing tests unless they are genuinely wrong or duplicate. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6e1b9ee --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,176 @@ +# strands-compose — Copilot Instructions + +This is **strands-compose**: a declarative multi-agent orchestration library for [strands-agents](https://github.com/strands-agents/sdk-python). +It reads YAML configs and returns fully wired, plain `strands` objects — no wrappers, no subclasses. + +--- + +## Architecture — NON-NEGOTIABLE + +1. **Strands-first** — always check `.venv/lib/python*/site-packages/strands/` before implementing anything. If strands provides it, use it directly. +2. **Thin wrapper** — translate YAML → Python objects, then get out of the way. +3. **Composition over inheritance** — small, focused components that compose. +4. **Explicit over implicit** — no auto-registration, no global singletons. +5. **Single responsibility** — each module does one thing. +6. **Testable in isolation** — no global state, every unit testable without other components. + +## Python Rules + +- `from __future__ import annotations` at the top of every module. +- Every public function/method/class must be fully typed — parameters and return type. +- Use `X | None`, `X | Y`, `list`, `dict`, `tuple` — never `Optional`, `Union`, `List`, `Dict`. +- Google-style docstrings on every public class, function, and method. +- Class docstring goes on `__init__`, not the class body. +- Early returns always — handle edge cases first, max 3 nesting levels. +- Raise specific exceptions (`ValueError`, `KeyError`, `TypeError`, `RuntimeError`) with context. +- Never silently swallow exceptions. No bare `except:`. +- Return copies from properties: `return list(self._items)`. +- `logging.getLogger(__name__)` — never `print()` for diagnostics. +- No `eval()`, `exec()`, `pickle` for untrusted data, `subprocess(shell=True)`. +- No hardcoded secrets — use env vars. +- Import order: stdlib → third-party → local (ruff-enforced). +- `__all__` only in `__init__.py`. + +## Naming + +- Classes: `PascalCase` | functions/methods: `snake_case` | constants: `UPPER_SNAKE_CASE` | private: `_prefix` +- No abbreviations in public API. Boolean params: `is_`, `has_`, `enable_` prefixes. + +## Key Strands APIs (do NOT reimplement) + +| What | Import path | +|------|-------------| +| `Agent` | `strands.agent.agent` | +| Hook events | `strands.hooks.events` — `BeforeInvocationEvent`, `AfterInvocationEvent`, `BeforeModelCallEvent`, `AfterModelCallEvent`, `BeforeToolCallEvent`, `AfterToolCallEvent` | +| `HookProvider` | `strands.hooks` — implement `register_hooks(registry)` | +| `MCPClient` | `strands.tools.mcp.mcp_client` | +| `SessionManager` | `strands.session` — `FileSessionManager`, `S3SessionManager` | +| Multi-agent | `strands.multiagent` — `Swarm`, `Graph` | +| `ToolRegistry` | `strands.tools.registry` | +| `@tool` decorator | `strands.tools.decorator` | + +## Testing + +- Every public function gets at least one test. Test behavior, not implementation. +- Use pytest fixtures, `parametrize`, `tmp_path`. Mock external dependencies. +- Name tests descriptively: `test_interpolate_missing_var_without_default_raises_value_error`. + +## Tooling + +```bash +uv run just install # install deps + git hooks (once after clone) +uv run just check # lint + type check + security scan +uv run just test # pytest with coverage (≥70%) +uv run just format # auto-format with ruff +``` + +## Directory Structure + +``` +src/strands_compose/ +├── __init__.py # Public API — load(), ComposeResult +├── models.py # Pydantic config models (AgentConfig, ModelConfig, …) +├── types.py # Shared type aliases +├── utils.py # Miscellaneous helpers +├── exceptions.py # Custom exception hierarchy +├── wire.py # Final assembly — wires all resolved objects into ComposeResult +├── config/ # YAML loading, validation, interpolation +│ ├── schema.py # JSON-schema for config validation +│ ├── interpolation.py # ${VAR:-default} interpolation +│ ├── loaders/ # File/string/dict loaders, helpers, validators +│ └── resolvers/ # Per-key resolvers (agents, models, mcp, hooks, …) +│ └── orchestrations/ # Orchestration builder and planner +│ ├── builders.py # Build delegate, swarm, graph objects +│ └── planner.py # Resolve orchestration config to plan +├── converters/ # Config dict → strands objects +│ ├── base.py # BaseConverter protocol +│ ├── openai.py # OpenAI-specific conversion +│ └── raw.py # Raw/passthrough conversion +├── hooks/ # Built-in HookProvider implementations +│ ├── event_publisher.py # Streaming event queue publisher +│ ├── max_calls_guard.py # Max tool-call circuit breaker +│ ├── stop_guard.py # Agent stop-signal hook +│ └── tool_name_sanitizer.py # Sanitize tool names for model compatibility +├── mcp/ # MCP server/client lifecycle +│ ├── client.py # MCPClient factory and wiring +│ ├── lifecycle.py # Server startup, readiness polling, shutdown +│ ├── server.py # Local Python server launcher +│ └── transports.py # Transport builders (stdio, streamable_http) +├── renderers/ # Terminal output rendering +│ ├── base.py # BaseRenderer protocol +│ └── ansi.py # ANSI colour renderer +├── startup/ # Post-load validation and reporting +│ ├── validator.py # Config correctness checks +│ └── report.py # Human-readable startup report +└── tools/ # Tool loading helpers + ├── extractors.py # Extract @tool functions from modules + ├── loaders.py # Import modules by path/name + └── wrappers.py # Wrap callables as strands tools + +tests/ +├── unit/ # Unit tests (mirrors src/ structure) +│ ├── config/ # Tests for config loading, schema, interpolation, resolvers +│ ├── converters/ # Tests for converter modules +│ ├── hooks/ # Tests for hook providers +│ ├── mcp/ # Tests for MCP lifecycle +│ ├── models/ # Tests for Pydantic config models +│ ├── renderers/ # Tests for renderers +│ └── startup/ # Tests for validator and report +├── integration/ # Integration tests (real strands objects) +└── examples/ # Smoke tests for all examples/ +``` + +## Logging Style + +Use `%s` interpolation with structured field-value pairs — never f-strings: + +```python +# Good +logger.debug("agent_id=<%s>, tool=<%s> | tool call started", agent_id, tool_name) +logger.warning("path=<%s>, reason=<%s> | config file not found", path, reason) + +# Bad +logger.debug(f"Tool {tool_name} called on agent {agent_id}") # no f-strings +logger.info("Config loaded.") # no punctuation +``` + +- Field-value pairs first: `key=` separated by commas +- Human-readable message after ` | ` +- `<>` around values (makes empty values visible) +- Lowercase messages, no trailing punctuation +- `%s` format strings, not f-strings (lazy evaluation) + +## Things to Do + +- Check `.venv/lib/python*/site-packages/strands/` before implementing — use strands if it exists +- `from __future__ import annotations` at the top of every module +- Fully type every function signature (parameters + return type) +- Google-style docstring on every public class, function, and method +- Put class docstrings on `__init__`, not the class body +- Early returns — handle edge cases first, max 3 nesting levels +- Raise specific exceptions (`ValueError`, `KeyError`, `TypeError`, `RuntimeError`) with context +- Return copies from properties exposing mutable state: `return list(self._items)` +- Use structured logging with `%s` and field-value pairs +- Run `uv run just check` then `uv run just test` before committing + +## Things NOT to Do + +- Don't reimplement what strands already provides — check first +- Don't use `Optional[X]`, `Union[X, Y]`, `List`, `Dict` — use `X | None`, `list`, `dict` +- Don't use `print()` for diagnostics — use `logging.getLogger(__name__)` +- Don't use f-strings in log calls — use `%s` interpolation +- Don't swallow exceptions silently — no bare `except:` +- Don't add `__all__` outside `__init__.py` +- Don't hardcode secrets — use env vars +- Don't use `eval()`, `exec()`, `pickle` for untrusted data, or `subprocess(shell=True)` +- Don't commit without running `uv run just check` +- Don't add comments about what changed or temporal context ("recently refactored", "moved from") + +## Agent-Specific Notes + +- Make the **smallest reasonable change** to achieve the goal — don't refactor unrelated code +- Prefer simple, readable, maintainable solutions over clever ones +- When unsure where something belongs, check the Directory Structure above +- Comments should explain **what** and **why**, never **when** or **how it changed** +- If you find something broken while working, fix it — don't leave it commented out +- Never add or change files outside the scope of the task diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b102266 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +version: 2 +updates: + - package-ecosystem: "pip" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 100 + commit-message: + prefix: ci + groups: + dev-dependencies: + patterns: + - "pytest" + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + open-pull-requests-limit: 100 + commit-message: + prefix: ci diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..14591ce --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +# Continuous Integration — runs on every push and pull request. + +name: CI + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + # ── Quality checks (format · lint · type · security) ────────────────────── + check: + name: Quality checks + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install just + uses: extractions/setup-just@v3 + + - name: Install dev deps + run: uv sync --all-groups --all-extras + + - name: Ruff — format check + run: uv run just check-format + + - name: Ruff — lint + run: uv run just check-code + + - name: ty — type check + run: uv run just check-type + + - name: Bandit — security scan + run: uv run just check-security + + # ── Tests ────────────────────────────────────────────────────────────────── + test: + name: Tests (Python ${{ matrix.python-version }}) + runs-on: ubuntu-latest + permissions: + contents: read + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + python-version: ${{ matrix.python-version }} + + - name: Install just + uses: extractions/setup-just@v3 + + - name: Install dev deps + run: uv sync --all-groups --all-extras + + - name: pytest with coverage + run: uv run just test diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..a18f1da --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,126 @@ +# Publish to PyPI — triggered by pushing a version tag (v*.*.*). +# Uses Trusted Publishing (OIDC — no API tokens required). +# +# One-time setup on PyPI: +# 1. Create a project for "strands-compose" at https://pypi.org/manage/account/publishing/ +# 2. Configure Trusted Publisher: +# - Owner: strands-compose +# - Repository: sdk-python +# - Workflow: publish.yml +# - Environment: release + +name: Publish + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]+" + - "v[0-9]+.[0-9]+.[0-9]+.post*" + - "v[0-9]+.[0-9]+.[0-9]+rc*" + +permissions: + contents: read + +jobs: + # ── Gate: run CI checks before publishing ───────────────────────────────── + ci: + uses: ./.github/workflows/ci.yml + permissions: + contents: read + + # ── Build distribution package ──────────────────────────────────────────── + # Pure Python package — one wheel (py3-none-any) runs on every OS and arch. + # No need for cibuildwheel or a platform matrix. + build: + name: Build + needs: ci + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v6 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Build wheel + sdist + run: uv build --out-dir dist/ + + - name: Upload build artifacts + uses: actions/upload-artifact@v7 + with: + name: dist + path: dist/ + if-no-files-found: error + + # ── Publish to PyPI ─────────────────────────────────────────────────────── + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + environment: release + permissions: + id-token: write + steps: + - name: Download build artifacts + uses: actions/download-artifact@v8 + with: + name: dist + path: dist/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + + # ── Create GitHub Release with CHANGELOG notes ──────────────────────────── + github-release: + name: GitHub Release + needs: publish + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + + - name: Install dev deps + run: uv sync --group dev + + - name: Extract changelog for this version + id: changelog + run: | + VERSION="${GITHUB_REF_NAME#v}" + NOTES=$(uv run python -c " + import re, pathlib + text = pathlib.Path('CHANGELOG.md').read_text() + match = re.search( + r'(?:^|\n)(## v?' + re.escape('$VERSION') + r'[^\n]*\n.*?)(?=\n## |\Z)', + text, re.DOTALL + ) + print(match.group(1).strip() if match else 'See CHANGELOG.md for details.') + " VERSION="$VERSION") + { + echo 'notes<> "$GITHUB_OUTPUT" + + - name: Download all artifacts + uses: actions/download-artifact@v8 + with: + path: dist-all/ + merge-multiple: true + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + body: ${{ steps.changelog.outputs.notes }} + files: dist-all/**/* + prerelease: ${{ contains(github.ref_name, 'rc') || contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f60c9cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,57 @@ +.env +.claude +.sessions + +# Ignore Python cache and compiled files +__pycache__/ +*.py[cod] +*.pyo +*.pyd +.ruff_cache + +# Ignore virtual environments +venv/ +env/ +.venv/ +.env/ + +# Ignore Jupyter Notebook checkpoints +.ipynb_checkpoints/ + +# Existing rules +python/* +.req.txt +*.egg-info/ +dist/ +build/ +requirements.txt +*.log +.logs/ + +# Pytest cache +.pytest_cache/ +.coverage* +htmlcov/ +.tox/ +.nox/ + +# VS Code settings +.vscode/ + +# Visual Studio files +*.user +*.suo +*.userosscache +*.sln.docstates + +# JetBrains IDEs (PyCharm, IntelliJ, etc.) +.idea/ +*.iml + +# Sublime Text +*.sublime-workspace +*.sublime-project + +# MacOS and Windows system files +.DS_Store +Thumbs.db diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9d91700 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,48 @@ +# https://pre-commit.com +# https://pre-commit.com/hooks.html + +# Use the active venv Python — avoids path issues on Windows where +# pre-commit looks for python3.12 at ~/.local/bin/python.exe +default_language_version: + python: python3 + +# Stages wired to git events: +# pre-commit -> runs on every `git commit` +# pre-push -> runs on every `git push` +# install-hooks in justfile registers both: +# uv run pre-commit install --hook-type=pre-push +# uv run pre-commit install --hook-type=commit-msg +default_stages: [pre-commit, pre-push] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: 'v5.0.0' + hooks: + - id: check-added-large-files + - id: check-case-conflict + - id: check-merge-conflict + - id: check-toml + - id: check-yaml + - id: debug-statements + - id: end-of-file-fixer + - id: mixed-line-ending + - id: trailing-whitespace + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.9.9' + hooks: + - id: ruff # lint — commit + push + - id: ruff-format # format — commit + push + + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + args: ['--baseline', '.secrets.baseline'] + files: \.py$ + + - repo: https://github.com/commitizen-tools/commitizen + rev: 'v4.4.1' + hooks: + - id: commitizen # validates commit message format + stages: [commit-msg] # only on commit-msg event, not push diff --git a/.secrets.baseline b/.secrets.baseline new file mode 100644 index 0000000..d0be180 --- /dev/null +++ b/.secrets.baseline @@ -0,0 +1,111 @@ +{ + "version": "1.5.0", + "plugins_used": [ + { + "name": "ArtifactoryDetector" + }, + { + "name": "AWSKeyDetector" + }, + { + "name": "AzureStorageKeyDetector" + }, + { + "name": "Base64HighEntropyString", + "limit": 4.5 + }, + { + "name": "BasicAuthDetector" + }, + { + "name": "CloudantDetector" + }, + { + "name": "HexHighEntropyString", + "limit": 3.0 + }, + { + "name": "IbmCloudIamDetector" + }, + { + "name": "IbmCosHmacDetector" + }, + { + "name": "JwtTokenDetector" + }, + { + "name": "KeywordDetector", + "keyword_exclude": "" + }, + { + "name": "MailchimpDetector" + }, + { + "name": "NpmDetector" + }, + { + "name": "PrivateKeyDetector" + }, + { + "name": "SlackDetector" + }, + { + "name": "SoftlayerDetector" + }, + { + "name": "SquareOAuthDetector" + }, + { + "name": "StripeDetector" + }, + { + "name": "TwilioKeyDetector" + } + ], + "filters_used": [ + { + "path": "detect_secrets.filters.allowlist.is_line_allowlisted" + }, + { + "path": "detect_secrets.filters.common.is_baseline_file", + "filename": ".secrets.baseline" + }, + { + "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" + }, + { + "path": "detect_secrets.filters.regex.should_exclude_file", + "pattern": [] + } + ], + "results": {}, + "generated_at": "2026-02-19T21:08:18Z" +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2b2acef --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,176 @@ +# strands-compose — Agent Instructions + +This is **strands-compose**: a declarative multi-agent orchestration library for [strands-agents](https://github.com/strands-agents/sdk-python). +It reads YAML configs and returns fully wired, plain `strands` objects — no wrappers, no subclasses. + +--- + +## Architecture — NON-NEGOTIABLE + +1. **Strands-first** — always check `.venv/lib/python*/site-packages/strands/` before implementing anything. If strands provides it, use it directly. +2. **Thin wrapper** — translate YAML → Python objects, then get out of the way. +3. **Composition over inheritance** — small, focused components that compose. +4. **Explicit over implicit** — no auto-registration, no global singletons. +5. **Single responsibility** — each module does one thing. +6. **Testable in isolation** — no global state, every unit testable without other components. + +## Python Rules + +- `from __future__ import annotations` at the top of every module. +- Every public function/method/class must be fully typed — parameters and return type. +- Use `X | None`, `X | Y`, `list`, `dict`, `tuple` — never `Optional`, `Union`, `List`, `Dict`. +- Google-style docstrings on every public class, function, and method. +- Class docstring goes on `__init__`, not the class body. +- Early returns always — handle edge cases first, max 3 nesting levels. +- Raise specific exceptions (`ValueError`, `KeyError`, `TypeError`, `RuntimeError`) with context. +- Never silently swallow exceptions. No bare `except:`. +- Return copies from properties: `return list(self._items)`. +- `logging.getLogger(__name__)` — never `print()` for diagnostics. +- No `eval()`, `exec()`, `pickle` for untrusted data, `subprocess(shell=True)`. +- No hardcoded secrets — use env vars. +- Import order: stdlib → third-party → local (ruff-enforced). +- `__all__` only in `__init__.py`. + +## Naming + +- Classes: `PascalCase` | functions/methods: `snake_case` | constants: `UPPER_SNAKE_CASE` | private: `_prefix` +- No abbreviations in public API. Boolean params: `is_`, `has_`, `enable_` prefixes. + +## Key Strands APIs (do NOT reimplement) + +| What | Import path | +|------|-------------| +| `Agent` | `strands.agent.agent` | +| Hook events | `strands.hooks.events` — `BeforeInvocationEvent`, `AfterInvocationEvent`, `BeforeModelCallEvent`, `AfterModelCallEvent`, `BeforeToolCallEvent`, `AfterToolCallEvent` | +| `HookProvider` | `strands.hooks` — implement `register_hooks(registry)` | +| `MCPClient` | `strands.tools.mcp.mcp_client` | +| `SessionManager` | `strands.session` — `FileSessionManager`, `S3SessionManager` | +| Multi-agent | `strands.multiagent` — `Swarm`, `Graph` | +| `ToolRegistry` | `strands.tools.registry` | +| `@tool` decorator | `strands.tools.decorator` | + +## Testing + +- Every public function gets at least one test. Test behavior, not implementation. +- Use pytest fixtures, `parametrize`, `tmp_path`. Mock external dependencies. +- Name tests descriptively: `test_interpolate_missing_var_without_default_raises_value_error`. + +## Tooling + +```bash +uv run just install # install deps + git hooks (once after clone) +uv run just check # lint + type check + security scan +uv run just test # pytest with coverage (≥70%) +uv run just format # auto-format with ruff +``` + +## Directory Structure + +``` +src/strands_compose/ +├── __init__.py # Public API — load(), ComposeResult +├── models.py # Pydantic config models (AgentConfig, ModelConfig, …) +├── types.py # Shared type aliases +├── utils.py # Miscellaneous helpers +├── exceptions.py # Custom exception hierarchy +├── wire.py # Final assembly — wires all resolved objects into ComposeResult +├── config/ # YAML loading, validation, interpolation +│ ├── schema.py # JSON-schema for config validation +│ ├── interpolation.py # ${VAR:-default} interpolation +│ ├── loaders/ # File/string/dict loaders, helpers, validators +│ └── resolvers/ # Per-key resolvers (agents, models, mcp, hooks, …) +│ └── orchestrations/ # Orchestration builder and planner +│ ├── builders.py # Build delegate, swarm, graph objects +│ └── planner.py # Resolve orchestration config to plan +├── converters/ # Config dict → strands objects +│ ├── base.py # BaseConverter protocol +│ ├── openai.py # OpenAI-specific conversion +│ └── raw.py # Raw/passthrough conversion +├── hooks/ # Built-in HookProvider implementations +│ ├── event_publisher.py # Streaming event queue publisher +│ ├── max_calls_guard.py # Max tool-call circuit breaker +│ ├── stop_guard.py # Agent stop-signal hook +│ └── tool_name_sanitizer.py # Sanitize tool names for model compatibility +├── mcp/ # MCP server/client lifecycle +│ ├── client.py # MCPClient factory and wiring +│ ├── lifecycle.py # Server startup, readiness polling, shutdown +│ ├── server.py # Local Python server launcher +│ └── transports.py # Transport builders (stdio, streamable_http) +├── renderers/ # Terminal output rendering +│ ├── base.py # BaseRenderer protocol +│ └── ansi.py # ANSI colour renderer +├── startup/ # Post-load validation and reporting +│ ├── validator.py # Config correctness checks +│ └── report.py # Human-readable startup report +└── tools/ # Tool loading helpers + ├── extractors.py # Extract @tool functions from modules + ├── loaders.py # Import modules by path/name + └── wrappers.py # Wrap callables as strands tools + +tests/ +├── unit/ # Unit tests (mirrors src/ structure) +│ ├── config/ # Tests for config loading, schema, interpolation, resolvers +│ ├── converters/ # Tests for converter modules +│ ├── hooks/ # Tests for hook providers +│ ├── mcp/ # Tests for MCP lifecycle +│ ├── models/ # Tests for Pydantic config models +│ ├── renderers/ # Tests for renderers +│ └── startup/ # Tests for validator and report +├── integration/ # Integration tests (real strands objects) +└── examples/ # Smoke tests for all examples/ +``` + +## Logging Style + +Use `%s` interpolation with structured field-value pairs — never f-strings: + +```python +# Good +logger.debug("agent_id=<%s>, tool=<%s> | tool call started", agent_id, tool_name) +logger.warning("path=<%s>, reason=<%s> | config file not found", path, reason) + +# Bad +logger.debug(f"Tool {tool_name} called on agent {agent_id}") # no f-strings +logger.info("Config loaded.") # no punctuation +``` + +- Field-value pairs first: `key=` separated by commas +- Human-readable message after ` | ` +- `<>` around values (makes empty values visible) +- Lowercase messages, no trailing punctuation +- `%s` format strings, not f-strings (lazy evaluation) + +## Things to Do + +- Check `.venv/lib/python*/site-packages/strands/` before implementing — use strands if it exists +- `from __future__ import annotations` at the top of every module +- Fully type every function signature (parameters + return type) +- Google-style docstring on every public class, function, and method +- Put class docstrings on `__init__`, not the class body +- Early returns — handle edge cases first, max 3 nesting levels +- Raise specific exceptions (`ValueError`, `KeyError`, `TypeError`, `RuntimeError`) with context +- Return copies from properties exposing mutable state: `return list(self._items)` +- Use structured logging with `%s` and field-value pairs +- Run `uv run just check` then `uv run just test` before committing + +## Things NOT to Do + +- Don't reimplement what strands already provides — check first +- Don't use `Optional[X]`, `Union[X, Y]`, `List`, `Dict` — use `X | None`, `list`, `dict` +- Don't use `print()` for diagnostics — use `logging.getLogger(__name__)` +- Don't use f-strings in log calls — use `%s` interpolation +- Don't swallow exceptions silently — no bare `except:` +- Don't add `__all__` outside `__init__.py` +- Don't hardcode secrets — use env vars +- Don't use `eval()`, `exec()`, `pickle` for untrusted data, or `subprocess(shell=True)` +- Don't commit without running `uv run just check` +- Don't add comments about what changed or temporal context ("recently refactored", "moved from") + +## Agent-Specific Notes + +- Make the **smallest reasonable change** to achieve the goal — don't refactor unrelated code +- Prefer simple, readable, maintainable solutions over clever ones +- When unsure where something belongs, check the Directory Structure above +- Comments should explain **what** and **why**, never **when** or **how it changed** +- If you find something broken while working, fix it — don't leave it commented out +- Never add or change files outside the scope of the task diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..0f99835 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## v0.1.0 — 2026-03-23 + +Initial public release of **strands-compose** — declarative multi-agent orchestration for [strands-agents](https://github.com/strands-agents/sdk-python). + +### Added + +- **YAML-first configuration** — define models, agents, tools, hooks, MCP servers, and orchestration topology in a single YAML file +- **Full YAML power** — environment variable interpolation (`${VAR:-default}`), anchors (`&ref` / `*ref`), `x-` scratch-pad keys, and multi-file config merging +- **Multi-model support** — Bedrock, OpenAI, Ollama, Gemini; swap provider with one line +- **MCP servers & clients** — launch local Python servers, connect to remote HTTP endpoints, or spawn stdio subprocesses; lifecycle management with startup ordering, readiness polling, and graceful shutdown +- **Orchestration modes** — Delegate (agent-as-tool), Swarm (peer handoffs), Graph (DAG pipelines) — arbitrarily nestable +- **Event streaming** — unified async event queue across any orchestration depth (tokens, tool calls, handoffs, completions) +- **Session persistence** — file, S3, or Bedrock AgentCore Memory backends; agents remember across restarts +- **Custom agent factories** — plug in your own `Agent` subclass or factory via the `type:` key +- **Hooks** — lifecycle callbacks (`before_invoke`, `after_invoke`, etc.) declared in YAML and implemented in Python +- **`load()` API** — single entry point that resolves, validates, and wires the full agent system; returns plain `strands` objects with no wrappers + +### Contributors + +- [@galuszkm](https://github.com/galuszkm) — initial design and implementation diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..f32d217 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# strands-compose — Claude Instructions + +@AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..a7a9455 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,115 @@ +# Contributing to strands-compose + +Thank you for your interest in contributing! Whether it's a bug report, new feature, correction, or additional documentation, we greatly value feedback and contributions from our community. + +Please read through this document before submitting any issues or pull requests to ensure we have all the necessary information to effectively respond to your bug report or contribution. + +## Reporting Bugs / Feature Requests + +We welcome you to use [GitHub Issues](https://github.com/strands-compose/sdk-python/issues) to report bugs or suggest features. + +When filing an issue, please check [existing issues](https://github.com/strands-compose/sdk-python/issues) first and try to include: + +- A reproducible test case or series of steps +- The version of strands-compose being used +- Any modifications you've made relevant to the bug +- A minimal `config.yaml` that reproduces the issue + +## Finding Contributions to Work On + +Looking at existing issues is a great way to find something to contribute. Before starting work: + +1. Check if someone is already assigned or working on it +2. Comment on the issue to express your interest +3. Wait for maintainer confirmation before beginning significant work + +## Development Tenets + +These principles guide every design decision in strands-compose. When contributing, please keep them in mind: + +1. **Strands-first** — if strands provides it, use it; don't wrap unnecessarily +2. **Composition over inheritance** — small, focused components that compose +3. **Explicit over implicit** — no auto-registration, no global singletons +4. **Single responsibility** — each module does one thing +5. **Testable in isolation** — no global state, unit-testable without other components +6. **Thin wrapper** — translate YAML to Python objects, then get out of the way + +## Development Environment + +### Prerequisites + +- Python 3.11+ +- [uv](https://docs.astral.sh/uv/) package manager +- Git + +### Getting Started + +```bash +git clone https://github.com/strands-compose/sdk-python +cd sdk-python +uv run just install +``` + +This installs all dependencies **and** wires the git hooks in one step. +If you only want to (re-)install the hooks later: + +```bash +uv run just install-hooks +``` + +### Pre-commit Hooks + +Three hook stages are registered automatically by `just install-hooks`: + +| Stage | Triggered by | Runs | +|---|---|---| +| `pre-commit` | `git commit` | ruff lint + format, file checks, detect-secrets | +| `pre-push` | `git push` | same as above | +| `commit-msg` | `git commit` | commitizen validates conventional commit format | + +> **Note:** `.git/hooks/` is not tracked by git. Every fresh clone requires running `uv run just install-hooks` once. + +### Quality Checks + +```bash +uv run just check # format + lint + type check + security +uv run just test # pytest with coverage (≥70%) +uv run just format # auto-format with Ruff +``` + +### Coding Standards + +All coding standards — type annotations, docstrings, naming, module organization, testing, and security rules — are documented in **[AGENTS.md](AGENTS.md)**. This is the single source of truth for how code should be written in this project. + +## Contributing via Pull Requests + +Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: + +1. You are working against the latest source on the `main` branch +2. You check existing open and recently merged pull requests to make sure someone else hasn't addressed the problem already +3. You open an issue to discuss any significant work — we would hate for your time to be wasted + +To send us a pull request: + +1. Create a branch from `main` +2. Make your changes — focus on the specific contribution +3. Run `uv run just check && uv run just test` +4. Commit using [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`, `chore:`) +5. Open a PR with a clear description of what and why +6. Pay attention to any automated CI failures and stay involved in the conversation + +### PR Checklist + +- [ ] All checks pass (`uv run just check`) +- [ ] Tests pass with adequate coverage (`uv run just test`) +- [ ] New public APIs have docstrings and tests +- [ ] No hardcoded secrets or credentials +- [ ] Changes are focused — one concern per PR + +## Security Issue Notifications + +If you discover a potential security issue in this project we ask that you notify us via [GitHub Security Advisories](https://github.com/strands-compose/sdk-python/security/advisories/new). Please do **not** create a public GitHub issue. + +## Licensing + +See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 0000000..e2ccf2b --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,3 @@ +# strands-compose — Gemini Instructions + +See [AGENTS.md](AGENTS.md) for the full development guide, coding conventions, directory structure, and rules. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..67db858 --- /dev/null +++ b/LICENSE @@ -0,0 +1,175 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e3f5e74 --- /dev/null +++ b/README.md @@ -0,0 +1,384 @@ +
+ strands-compose + + # Strands Compose + + **Declarative multi-agent orchestration for [strands-agents](https://github.com/strands-agents/sdk-python) — wire entire agent systems with YAML** + +

+ Python 3.11+ + PyPI version + Strands Agents + License +

+
+ +> [!IMPORTANT] +> Community project — not affiliated with AWS or the strands-agents team. Bugs here? [Open an issue](https://github.com/galuszkm/strands-compose/issues). Bugs in the underlying SDK? Head to [strands-agents](https://github.com/strands-agents/sdk-python). + +## What is this? + +> **Think Docker Compose, but for AI agents** + +[Strands](https://github.com/strands-agents/sdk-python) is a powerful agent SDK. But once you have more than one agent, a few MCP servers, safety hooks, and shared models — you end up writing the same plumbing over and over. **strands-compose kills that boilerplate.** + +You describe the shape of your agent system in YAML, and strands-compose resolves, validates, and starts everything — models, MCP servers & clients, hooks, tools, orchestration topology — as a live, fully wired multi-agent system. + +**Already working with strands? Guess what — you already know strands-compose.** After `load()` resolves your YAML, what you get back are plain strands objects. Every agent **is** a `strands.Agent`. Every MCP client **is** a `strands.tools.mcp.MCPClient`. Every orchestrator **is** a `strands.multiagent.Swarm` or `Graph` or just `strands.Agent`. No wrappers, no subclasses, no magic. Just the real deal, fully wired and ready to go. + +```yaml +models: + default: + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 + +agents: + researcher: + model: default + system_prompt: "You research topics." + tools: [strands_tools:http_request] + + writer: + model: default + system_prompt: "You write reports." + + coordinator: + model: default + system_prompt: "Coordinate research and writing." + +orchestrations: + team_leader: + mode: delegate + entry_name: coordinator + connections: + - agent: researcher + description: "Research a topic." + - agent: writer + description: "Write the report." + +entry: team_leader +``` + +```python +from strands_compose import load + +resolved = load("config.yaml") + +result = resolved.entry("Write a report about quantum computing.") +print(result) +``` + +Three agents, orchestration wiring, model sharing — **zero plumbing code**. + +--- + +## Why this changes everything + +Your entire agent network — models, prompts, tools, hooks, MCP servers, orchestration topology — captured in a single YAML file and maybe a few Python files for custom tools or hooks. That's it. That's your agent environment. Here's what that unlocks: + +### 🔖 Version it + +Push to Git. Tag it. Diff two versions and see exactly what changed — which prompt was tweaked, which model was swapped, which hook was added. Your agent system gets the same auditability as your infrastructure code. No more "I think someone changed the system prompt last Tuesday." + +### 📦 Build a registry + +A folder of YAML configs — one per agent environment. `production.yaml`, `staging.yaml`, `experiment-42.yaml`. Each is a complete, self-contained snapshot of an agent system. Load any of them with `load("experiment-42.yaml")`. That's your agent environments registry — no platform needed. + +### 🧪 Automate experiments + +Your entire config is data, so you can *generate* it. Build 20 variations — different models, different prompts, different tool combinations — and run them all in CI. With session persistence, every agent interaction is tracked. Point another strands-compose pipeline at those session logs to analyze results, compare quality, compute metrics. You're benchmarking agent systems *with agent systems*. + +### 🔁 Reproduce anything + +A bug report comes in. You have the exact YAML config, the session ID, the full conversation trace. Load it, replay it, debug it. No "works on my machine" — the config *is* the machine. + +### CRAZY, right?! + +--- + +## What's in the box + +| Feature | What it does | +|---------|-------------| +| **YAML-first config** | Models, agents, tools, hooks, MCP, orchestrations — all in one file | +| **Full YAML power** | Variables (`${VAR:-default}`), anchors (`&ref` / `*ref`), `x-` scratch pads, multi-file merge | +| **Multi-model support** | Bedrock, OpenAI, Ollama, Gemini — swap with one line | +| **MCP servers & clients** | Launch local servers from Python files, connect to remote HTTP endpoints, or spawn stdio subprocesses | +| **MCP lifecycle management** | Startup ordering, readiness polling, graceful shutdown — servers before clients, always | +| **Orchestration modes** | Delegate (agent-as-tool), Swarm (peer handoffs), Graph (DAG pipelines) — arbitrarily nestable | +| **Event streaming** | Unified async event queue across any orchestration depth — tokens, tool calls, handoffs, completions | +| **Session persistence** | File, S3, or [Bedrock AgentCore Memory](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/memory.html) — agents remember across restarts | +| **Custom agent factories** | Plug your own `Agent` subclass or factory via `type:` | +| **Deployment-agnostic** | Pure core library — no HTTP server, no deployment opinions baked in | + +--- + +## How it works — the loading pipeline + +When you call `load("config.yaml")`, strands-compose runs a deterministic pipeline: + +``` +YAML source(s) + │ + ├─ Parse & strip x-* anchors + ├─ Interpolate ${VAR:-default} variables + ├─ Sanitize collection keys + ├─ Merge (if multi-file) + │ + ├─ Validate against Pydantic schema + │ + ├─ Resolve infrastructure (models, MCP servers/clients, session managers) + ├─ Start MCP lifecycle (servers up → clients connect) + │ + ├─ Create agents (with tools, hooks, MCP clients attached) + ├─ Wire orchestrations (delegate/swarm/graph, topological sort) + │ + └─ Return ResolvedConfig — ready to call +``` + +Every step is explicit. Every error is caught early with a clear message. The pipeline is the same whether you load one file or merge five — `load(["base.yaml", "agents.yaml", "mcp.yaml"])` just works. + +--- + +## YAML superpowers + +**strands-compose** gives you Docker Compose-style variable interpolation **plus** full YAML anchor/alias support. DRY configs that adapt to any environment: + +```yaml +vars: + MODEL: ${MODEL:-us.anthropic.claude-sonnet-4-6-v1:0} + TONE: ${TONE:-friendly} + +x-base: &base_prompt | + You are a ${TONE} assistant. + Keep answers clear and concise. + +x-hooks: &safety_hooks + - type: strands_compose.hooks:MaxToolCallsGuard + params: { max_calls: 15 } + - type: strands_compose.hooks:ToolNameSanitizer + +models: + default: + provider: bedrock + model_id: ${MODEL} + +agents: + assistant: + model: default + system_prompt: *base_prompt + hooks: *safety_hooks + +entry: assistant +``` + +Override at runtime: `TONE=formal MODEL=us.anthropic.claude-sonnet-4-6-v1:0 python main.py` + +Split large configs across files — models in one, agents in another, MCP in a third — and merge them with `load(["base.yaml", "agents.yaml"])`. Each file interpolates its own `vars:` independently, collections merge, and duplicates are caught. + +--- + +## Getting started + +```bash +git clone https://github.com/strands-compose/sdk-python +cd sdk-python +uv sync --all-groups --all-extras +``` + +Install from PyPI: + +```bash +pip install strands-compose # Bedrock (default) +pip install strands-compose[ollama] # + Ollama +pip install strands-compose[openai] # + OpenAI +pip install strands-compose[gemini] # + Gemini +``` + +Create a `config.yaml`: + +```yaml +models: + default: + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 + +agents: + assistant: + model: default + system_prompt: "You are a helpful assistant." + +entry: assistant +``` + +Run it: + +```python +from strands_compose import load + +resolved = load("config.yaml") + +with resolved.mcp_lifecycle: + result = resolved.entry("Hello!") + print(result) +``` + +--- + +## Examples + +Every example is a self-contained folder with a `README.md`, `config.yaml`, and `main.py`. Start from the top and work your way down — each one builds on concepts from the previous. + +| # | Example | What it shows | +|---|---------|---------------| +| 01 | [Minimal](examples/01_minimal/) | `load()` one-liner — the simplest possible agent | +| 02 | [Vars & Anchors](examples/02_vars_and_anchors/) | `${VAR:-default}` interpolation and YAML `&anchor` / `*alias` reuse | +| 03 | [Tools](examples/03_tools/) | `tools:` — auto-load `@tool` functions from Python files | +| 04 | [Session](examples/04_session/) | `session_manager:` — persistent memory across turns and restarts | +| 05 | [Hooks](examples/05_hooks/) | `hooks:` — `MaxToolCallsGuard`, `ToolNameSanitizer`, and custom hooks | +| 06 | [MCP](examples/06_mcp/) | All three MCP modes: local server, remote URL, stdio subprocess | +| 07 | [Delegate](examples/07_delegate/) | `mode: delegate` — coordinator routes work to specialist agents | +| 08 | [Swarm](examples/08_swarm/) | `mode: swarm` — peer agents hand off to each other autonomously | +| 09 | [Graph](examples/09_graph/) | `mode: graph` — deterministic DAG pipeline between agents | +| 10 | [Nested](examples/10_nested/) | Nested orchestration — Swarm inside a Delegate | +| 11 | [Multi-file](examples/11_multi_file_config/) | Split config across files — infra in one YAML, agents in another | +| 12 | [Streaming](examples/12_streaming/) | `wire_event_queue()` — stream every token, tool call, and handoff live | +| 13 | [Graph conditions](examples/13_graph_conditions/) | Conditional edges — `condition:`, `reset_on_revisit`, `max_node_executions` | +| 14 | [Agent factory](examples/14_agent_factory/) | `type:` + `agent_kwargs:` — custom agent factory instead of `Agent()` | + +```bash +# Run any example +uv run python examples/01_minimal/main.py +``` + +--- + +## Multi-agent orchestration + +**strands-compose** supports 3 orchestration modes from strands. They can be nested arbitrarily — a delegate target can be a swarm, a graph node can be a delegate. + +### Delegate — agent as a tool + +The coordinator calls sub-agents like tool functions. Best for hub-and-spoke patterns: + +```yaml +orchestrations: + team_leader: + mode: delegate + entry_name: coordinator # Agent declared in `agents:` + connections: + - agent: researcher + description: "Research the topic." + - agent: writer + description: "Write the report." +``` + +### Swarm — autonomous handoffs + +Peer agents pass control to each other. No central coordinator — agents decide when to hand off: + +```yaml +orchestrations: + review_team: + mode: swarm + entry_name: drafter # Swarm entry - agent name + agents: [drafter, reviewer, tech_lead] # Agents declared in `agents:` + max_handoffs: 10 +``` + +### Graph — deterministic DAG pipeline + +Agents execute in dependency order. Independent nodes run in parallel. Supports conditional edges: + +```yaml +orchestrations: + blog: + mode: graph + entry_name: writer # Graph entry - agent name + edges: # We use agent names to define edges + - from: writer + to: reviewer + - from: reviewer + to: writer + condition: ./conditions.py:needs_revision + - from: reviewer + to: publisher + condition: ./conditions.py:is_approved +``` + +### Nested orchestrations + +Named orchestrations reference each other. A swarm becomes a delegate tool, a delegate becomes a graph node — compose them however you want: + +```yaml +orchestrations: + content_team: # This swarm is plugged in as a tool for the team_leader + mode: swarm + entry_name: researcher + agents: [researcher, writer, reviewer] + + team_leader: + mode: delegate + entry_name: coordinator + connections: + - agent: content_team # Nested swarm as a delegate tool + description: "Content creation team." + - agent: qa_bot # Neasted agent as a delegate tool + description: "Quality assurance." + +entry: team_leader +``` + +**strands-compose** topologically sorts all orchestrations, builds inner ones first, then wires them as tools or nodes for outer ones. Circular dependencies are caught at load time. + +--- + +## Streaming-ready by design + +When you have a 3-level nested orchestration — a delegate calling a swarm that uses graph nodes — you still want to know exactly what's happening. Which agent is thinking? What tool just fired? When did a handoff occur? + +**`EventPublisher`** is a strands `HookProvider` that captures every lifecycle event and publishes it to a shared async queue. The trick: `wire_event_queue()` attaches publishers to **every agent in your entire system** — no matter how deeply nested — so all events flow to one place. + +```python +import asyncio +from strands_compose import AnsiRenderer, load + +async def main(): + resolved = load("config.yaml") + queue = resolved.wire_event_queue() + + async def invoke(): + try: + await resolved.entry.invoke_async("Analyse LLM trends.") + finally: + await queue.close() + + asyncio.create_task(invoke()) + + renderer = AnsiRenderer() + while (event := await queue.get()) is not None: + renderer.render(event) + renderer.flush() + +asyncio.run(main()) +``` + +Event types: `TOKEN`, `REASONING`, `TOOL_START`, `TOOL_END`, `NODE_START`, `NODE_STOP`, `HANDOFF`, `COMPLETE` — each carrying `{type, agent_name, timestamp, data}`. Enough for a real-time frontend, a log aggregator, or a debugging dashboard. The `AnsiRenderer` gives you coloured terminal output out of the box — agent names, tool calls, reasoning traces, all streaming live. + +--- + +## Developer setup + +```bash +git clone https://github.com/strands-compose/sdk-python +cd sdk-python +uv run just install # install deps + wire git hooks (run once after clone) + +uv run just check # lint + type check + security scan +uv run just test # pytest with coverage +uv run just format # auto-format (Ruff) +``` + +> Re-install hooks after a fresh clone or if hooks stop running: `uv run just install-hooks` + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the full contribution guide and [CHANGELOG.md](CHANGELOG.md) for release history. diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000..896dd33 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,50 @@ +# Releasing strands-compose + +Releases are driven by [Conventional Commits](https://www.conventionalcommits.org/) and automated via [commitizen](https://commitizen-tools.github.io/commitizen/) + GitHub Actions. + +## Release Flow + +```bash +# 1. Ensure main is green +uv run just check +uv run just test + +# 2. Preview the bump (no changes written) +uv run just release-dry + +# 3. Bump version, update CHANGELOG, create tag +uv run just release + +# 4. Push to trigger PyPI publish +git push origin main --tags +``` + +That's it. The `publish.yml` workflow builds, publishes to PyPI via Trusted Publishing, and creates a GitHub Release automatically. + +## How Versioning Works + +We follow **Semantic Versioning** (`MAJOR.MINOR.PATCH`). Commit messages drive the bump: + +| Commit prefix | Bump | Example | +|---------------|------|---------| +| `fix:` | patch | `fix: handle empty tool name` | +| `feat:` | minor | `feat: add graph orchestration` | +| `feat!:` / `BREAKING CHANGE:` | major | `feat!: remove legacy API` | + +Use `uv run just commit-files` for the interactive commit wizard, or commit manually. + +## Release Candidates + +```bash +uv run cz bump --prerelease rc +git push origin main --tags +``` + +## Just Commands + +| Command | What it does | +|---------|-------------| +| `uv run just release-dry` | Preview next version + changelog | +| `uv run just release` | Bump, CHANGELOG, tag | +| `uv run just release-build` | Build wheel + sdist locally | +| `uv run just commit-files` | Interactive conventional commit | diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..d096c31 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Security Policy + +## Supported Versions + +| Version | Supported | +|---------|--------------------| +| 0.1.x | :white_check_mark: | + +## Reporting Security Issues + +We take security seriously. Please **do not** open a public GitHub issue for security vulnerabilities. + +Instead, please report via [GitHub Security Advisories](https://github.com/strands-compose/sdk-python/security/advisories/new). + +Include: +- Description of the vulnerability +- Steps to reproduce +- Potential impact +- Suggested fix (if any) + +## Security Practices + +strands-compose enforces the following (via Bandit + code review): + +- **No `eval()` or `exec()`** — config is parsed through Pydantic, never executed as code +- **No `subprocess` with `shell=True`** — MCP stdio transports use direct command execution +- **No hardcoded secrets** — all credentials resolved from environment variables +- **No `pickle`** — serialization uses JSON/YAML only +- **Strict input validation** — all YAML config validated against a Pydantic schema +- **Bandit scanning** — automated static security analysis on every commit (`uv run just check-security`) +- **Dependency auditing** — dependencies are pinned and regularly reviewed for known vulnerabilities diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..42007a0 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,31 @@ +# Support + +## Getting Help + +- **[README](README.md)** — overview and quick start +- **[AGENTS.md](AGENTS.md)** — coding standards, architecture principles, and strands API reference +- **[Examples](examples/)** — working examples with Python + YAML + +## Reporting Issues + +If you encounter a bug or have a feature request: + +1. Search [existing issues](https://github.com/strands-compose/sdk-python/issues) to avoid duplicates +2. Open a new issue with: + - Clear title describing the problem + - Steps to reproduce (for bugs) + - Minimal `config.yaml` that reproduces the issue + - Python version and strands-compose version + +## Security + +If you discover a potential security issue, please see [SECURITY.md](SECURITY.md). +- If you see tool name errors, ensure the sanitizer is included in your hooks list + +**Import errors:** +- Install optional dependencies: `pip install strands-compose[ollama]` for Ollama, `pip install strands-compose[openai]` for OpenAI +- Ensure Python 3.11+ is being used + +## Strands Agents SDK + +strands-compose is built on top of the [Strands Agents SDK](https://github.com/strands-agents/sdk-python). For questions about the underlying agent framework, model providers, or hook system, refer to the Strands documentation. diff --git a/docs/configuration/Chapter_01.md b/docs/configuration/Chapter_01.md new file mode 100644 index 0000000..349f573 --- /dev/null +++ b/docs/configuration/Chapter_01.md @@ -0,0 +1,71 @@ +# Chapter 1: The Basics — Your First Config + +[← Back to Table of Contents](README.md) + +--- + +A strands-compose config is a YAML file with a handful of top-level sections. The only truly **required** fields are `agents` (at least one) and `entry` (which agent or orchestration to call). + +Here is the absolute minimum: + +```yaml +agents: + assistant: + system_prompt: "You are a helpful assistant." + +entry: assistant +``` + +That's it. One agent, one entry point. Load it in Python: + +```python +from strands_compose import load + +resolved = load("config.yaml") +result = resolved.entry("Hello!") +print(result) +``` + +`resolved.entry` is a plain `strands.Agent` — nothing wrapped, nothing magic. You can call it, inspect it, pass it around — it's the real deal. + +## Root-Level Sections + +Here is the full list of top-level keys you can put in a config file: + +| Key | Type | Required | Description | +|-----|------|----------|-------------| +| `version` | string | No | Schema version. Only `"1"` is supported. Defaults to `"1"`. | +| `vars` | dict | No | Variable definitions for `${VAR}` interpolation. | +| `models` | dict | No | Named LLM model definitions. | +| `agents` | dict | **Yes** (at least one somewhere) | Named agent definitions. | +| `orchestrations` | dict | No | Named multi-agent orchestration definitions. | +| `mcp_servers` | dict | No | Named MCP server definitions (managed lifecycle). | +| `mcp_clients` | dict | No | Named MCP client connections. | +| `session_manager` | dict | No | Global session manager (inherited by all agents). | +| `entry` | string | **Yes** | Name of the agent or orchestration to use as the entry point. | +| `log_level` | string | No | Logging level for strands_compose. Default: `"WARNING"`. | + +Sections marked as **dict** are name-keyed dictionaries — you pick the name, and it becomes the identifier: + +```yaml +models: + my_fast_model: # <-- you chose this name + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 +``` + +The `x-` prefix is reserved for scratch-pad keys (YAML anchors) — they are **stripped** before validation and never reach the schema. More on that in [Chapter 4](Chapter_04.md). + +## What About `vars`? + +`vars` is special. It's consumed during interpolation and **removed** before schema validation. You will never see it in the final `AppConfig` object. It exists only to feed `${VAR}` references. Covered in [Chapter 3](Chapter_03.md). + +> **Tips & Tricks** +> +> - You can omit `models` entirely. If an agent doesn't specify a `model`, strands uses its default (Bedrock with the default model). Handy for quick prototyping. +> - `entry` must reference something defined in either `agents` or `orchestrations`. If it doesn't, you'll get a clear error at load time. +> - Names follow the pattern `[a-zA-Z0-9_-]` and are limited to 64 characters. Spaces and special characters are auto-sanitized to underscores. + +--- + +[Next: Chapter 2 — Models →](Chapter_02.md) diff --git a/docs/configuration/Chapter_02.md b/docs/configuration/Chapter_02.md new file mode 100644 index 0000000..0bf1c9d --- /dev/null +++ b/docs/configuration/Chapter_02.md @@ -0,0 +1,106 @@ +# Chapter 2: Models — Choosing Your LLM + +[← Back to Table of Contents](README.md) | [← Previous: The Basics](Chapter_01.md) + +--- + +The `models` section defines named LLM configurations. Each model has a `provider`, a `model_id`, and optional `params`. + +```yaml +models: + fast: + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 + + creative: + provider: openai + model_id: gpt-4o + params: + temperature: 0.9 + + local: + provider: ollama + model_id: llama3.2 + params: + ctx_size: 8192 +``` + +## Built-in Providers + +| Provider | Package Required | Example `model_id` | +|----------|-----------------|---------------------| +| `bedrock` | *(included)* | `us.anthropic.claude-sonnet-4-6-v1:0` | +| `openai` | `pip install strands-compose[openai]` | `gpt-4o` | +| `ollama` | `pip install strands-compose[ollama]` | `llama3.2` | +| `gemini` | `pip install strands-compose[gemini]` | `gemini-2.0-flash` | + +## How Agents Reference Models + +Agents reference models by **name** — the key you defined in the `models` section: + +```yaml +models: + smart: + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 + +agents: + analyst: + model: smart # <-- references the "smart" model above + system_prompt: "You analyze data." +``` + +## Inline Models + +Don't want to name a model? Define it inline directly on the agent: + +```yaml +agents: + analyst: + model: + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 + system_prompt: "You analyze data." +``` + +This is handy when only one agent uses a specific model — no need to pollute the `models` section. But if two agents share the same model config, **use a named model** to avoid duplication. + +## Custom Model Providers + +If the built-in four providers aren't enough, you can point `provider` to a custom `Model` subclass: + +```yaml +models: + my_custom: + provider: my_package.models:CustomModel + model_id: my-model-v1 + params: + api_key: ${API_KEY} +``` + +The class must be a subclass of `strands.models.Model`. The `model_id` and `params` are passed to its constructor. + +## The `params` Dict + +`params` is a pass-through dictionary — whatever you put in it gets forwarded as `**kwargs` to the model constructor. This means you can set any provider-specific parameter: + +```yaml +models: + tuned: + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 + params: + max_tokens: 4096 + temperature: 0.7 + top_p: 0.9 +``` + +> **Tips & Tricks** +> +> - When combined with `vars`, you can swap models at runtime: `MODEL=gpt-4o python main.py`. See [Chapter 3](Chapter_03.md). +> - If you omit `model` on an agent entirely, strands picks its built-in default (Bedrock). This is fine for quick tests but explicit is better for production. +> - The `params` dict preserves types from YAML — integers stay integers, floats stay floats. This matters for parameters like `max_tokens` that must be an int. + +--- + +[Next: Chapter 3 — Variables →](Chapter_03.md) diff --git a/docs/configuration/Chapter_03.md b/docs/configuration/Chapter_03.md new file mode 100644 index 0000000..6917bf5 --- /dev/null +++ b/docs/configuration/Chapter_03.md @@ -0,0 +1,117 @@ +# Chapter 3: Variables — Environment-Driven Config + +[← Back to Table of Contents](README.md) | [← Previous: Models](Chapter_02.md) + +--- + +strands-compose supports Docker Compose-style `${VAR}` interpolation. Define variables in the `vars` block, reference them anywhere with `${VAR}`, and optionally provide defaults with `${VAR:-fallback}`. + +```yaml +vars: + MODEL: ${MODEL:-us.anthropic.claude-sonnet-4-6-v1:0} + TONE: ${TONE:-friendly} + MAX_TOKENS: ${MAX_TOKENS:-1024} + +models: + default: + provider: bedrock + model_id: ${MODEL} + params: + max_tokens: ${MAX_TOKENS} + +agents: + assistant: + model: default + system_prompt: "You are a ${TONE} assistant." + +entry: assistant +``` + +## Lookup Order + +When strands-compose sees `${SOMETHING}`, it resolves it in this order: + +1. **`vars` block** — your YAML-defined variables +2. **Environment variables** — `os.environ` +3. **Default value** — the part after `:-` +4. **Error** — if none of the above, loading fails with a clear message + +So `${MODEL:-gpt-4o}` means: "use the `MODEL` var if defined, then check the environment, then fall back to `gpt-4o`." + +## Override at Runtime + +```bash +# Linux/macOS +TONE=formal MODEL=gpt-4o python main.py + +# Windows PowerShell +$env:TONE="formal"; $env:MODEL="gpt-4o"; python main.py +``` + +## Variable Chaining + +Variables can reference other variables: + +```yaml +vars: + BASE_MODEL: us.anthropic.claude-sonnet-4-6-v1:0 + MODEL: ${BASE_MODEL} +``` + +This works because strands-compose resolves `vars` in two sequential passes — the first pass resolves against environment variables, the second pass resolves cross-references between vars. Circular references (`A: ${B}`, `B: ${A}`) are caught and raise a clear error. + +## Type Preservation + +Here's a subtle but powerful feature: when the **entire** value is a single `${VAR}` reference (not embedded in a larger string), the original type is preserved. + +```yaml +vars: + MAX_TOKENS: 1024 # This is an integer in YAML + +models: + default: + provider: bedrock + model_id: some-model + params: + max_tokens: ${MAX_TOKENS} # Resolves to integer 1024, not string "1024" +``` + +But if you embed it in a string, it becomes a string: + +```yaml +system_prompt: "Use max ${MAX_TOKENS} tokens" # "Use max 1024 tokens" (string) +``` + +This is important for parameters that expect a specific type (like `max_tokens` needing an int). + +## Variables Without Defaults + +If you reference a variable that doesn't exist and has no default, loading fails immediately: + +```yaml +vars: + MODEL: ${REQUIRED_MODEL} # No :- default! +``` + +``` +ValueError: Variable '${REQUIRED_MODEL}' is not set in 'vars:' or environment, +and no default was provided. +Use ${REQUIRED_MODEL:-fallback} to set a fallback value. +``` + +This is intentional — it forces explicit configuration for deployment-critical values. + +## Per-Source Interpolation + +When you use [multi-file configs](Chapter_13.md), each file's `vars` block is interpolated independently before merging. File A's vars don't leak into File B's interpolation. + +> **Tips & Tricks** +> +> - Use variables for anything that changes between environments: model IDs, API endpoints, log levels, session directories. +> - The `${VAR:-default}` pattern is your best friend for making configs self-contained — they work out of the box but can be customized via environment. +> - `vars` is removed after interpolation — it never reaches schema validation. So you can put anything in there, even nested dicts and lists (though string/number values are most common). +> - Want to see what resolved? Use `load_config()` instead of `load()` — it returns the validated `AppConfig` without starting anything. + +--- + +[Next: Chapter 4 — YAML Anchors →](Chapter_04.md) diff --git a/docs/configuration/Chapter_04.md b/docs/configuration/Chapter_04.md new file mode 100644 index 0000000..b55559a --- /dev/null +++ b/docs/configuration/Chapter_04.md @@ -0,0 +1,115 @@ +# Chapter 4: YAML Anchors — DRY Config Blocks + +[← Back to Table of Contents](README.md) | [← Previous: Variables](Chapter_03.md) + +--- + +YAML has a built-in reuse mechanism: **anchors** (`&name`) and **aliases** (`*name`). strands-compose embraces this for eliminating copy-paste across your config. + +## The `x-` Scratch Pad + +Any top-level key starting with `x-` is treated as a scratch pad — it's stripped before schema validation. Use it to define reusable blocks: + +```yaml +# Define reusable blocks +x-base_prompt: &base_prompt | + You are a helpful assistant. + Always be concise and clear. + +x-safety_hooks: &safety_hooks + - type: strands_compose.hooks:MaxToolCallsGuard + params: { max_calls: 15 } + - type: strands_compose.hooks:ToolNameSanitizer + +x-model_params: &model_params + max_tokens: 2048 + temperature: 0.7 + +# Use them +models: + default: + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 + params: *model_params # Reuse the model params + +agents: + researcher: + model: default + system_prompt: *base_prompt # Reuse the prompt + hooks: *safety_hooks # Reuse the hooks + + writer: + model: default + system_prompt: *base_prompt # Same prompt, no copy-paste + hooks: *safety_hooks # Same hooks, no copy-paste + +entry: researcher +``` + +## How Anchors Work + +1. **Define** with `&name`: `x-my_block: &my_block { key: value }` +2. **Reference** with `*name`: `field: *my_block` + +The anchor creates a deep copy at the alias site. The `x-` prefix is a strands-compose convention — YAML anchors work on any key, but `x-` keys are cleaned up so they don't trigger "unknown field" errors. + +## Combining Anchors with Variables + +Anchors and variables work together beautifully: + +```yaml +vars: + TONE: ${TONE:-professional} + +x-base_prompt: &base_prompt | + You are a ${TONE} assistant. + Keep responses clear and structured. + +agents: + assistant: + system_prompt: *base_prompt # Gets "${TONE}" which is then interpolated +``` + +The order is: YAML parsing (anchors resolved) → anchor stripping (`x-*` removed) → variable interpolation (`${VAR}` replaced). So the alias `*base_prompt` is expanded first, and then `${TONE}` within it is interpolated. + +## Anchors for Type-Preserving Reuse + +Unlike variables (which can become strings when embedded), anchors always preserve the original YAML structure. An anchor on a dict gives you a dict, an anchor on a list gives you a list, an integer stays an integer: + +```yaml +x-model_params: ¶ms + max_tokens: 2048 # integer + temperature: 0.7 # float + +models: + fast: + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 + params: *params # max_tokens is still int 2048, not string "2048" +``` + +## Anchors You Can Define Anywhere + +You don't *have* to use `x-` keys. Anchors can be defined on any value: + +```yaml +models: + default: &default_model # Anchor on an entire model definition + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 + + backup: *default_model # Exact copy of 'default' +``` + +The `x-` prefix is simply cleaner for "scratch pad" blocks that don't belong to any real config section. + +> **Tips & Tricks** +> +> - Use `x-` blocks at the top of your file to define your project's "design system" — shared prompts, hook lists, model params. +> - Pair anchors with variables for maximum flexibility: anchors handle structure reuse, variables handle value swapping. +> - YAML anchors are resolved by the YAML parser itself — strands-compose doesn't even see them. This means they work exactly as documented in the YAML spec. +> - You can use `{ key: value }` inline syntax for short dicts in anchor definitions — great for concise params: `params: { max_calls: 15 }`. + +--- + +[Next: Chapter 5 — Tools →](Chapter_05.md) diff --git a/docs/configuration/Chapter_05.md b/docs/configuration/Chapter_05.md new file mode 100644 index 0000000..4f5b306 --- /dev/null +++ b/docs/configuration/Chapter_05.md @@ -0,0 +1,107 @@ +# Chapter 5: Tools — Giving Agents Superpowers + +[← Back to Table of Contents](README.md) | [← Previous: YAML Anchors](Chapter_04.md) + +--- + +The `tools` field on an agent is a list of spec strings that tell strands-compose where to find Python tool functions. + +```yaml +agents: + analyst: + model: default + tools: + - ./tools.py # All @tool functions from this file + - ./tools.py:count_words # One specific function + - ./utils/ # All @tool functions from all .py files in dir + - my_package.tools # All @tool functions from an installed module + - my_package.tools:special_function # One specific function from a module + - strands_tools:http_request # A tool from strands' built-in tools + system_prompt: "You analyze text using your tools." +``` + +## Spec Formats + +| Format | What It Loads | +|--------|---------------| +| `./file.py` | All `@tool`-decorated functions from the file | +| `./file.py:func_name` | One specific function (auto-wrapped with `@tool` if needed) | +| `./dir/` | All `@tool` functions from all `.py` files in directory (skips `_`-prefixed files) | +| `module.path` | All `@tool` functions from an installed Python module | +| `module.path:func_name` | One specific function from a module | + +## Writing Tool Functions + +Tool functions must be decorated with `@tool` from strands: + +```python +# tools.py +from strands.tools.decorator import tool + +@tool +def count_words(text: str) -> int: + """Count the number of words in the given text.""" + return len(text.split()) + +@tool +def reverse_text(text: str) -> str: + """Reverse the given text.""" + return text[::-1] +``` + +The decorator registers the function's name, docstring (used as the tool description for the LLM), and parameter schema (derived from type hints). Functions **without** `@tool` are silently ignored when scanning a file or module. + +## Single Function Lookups and Auto-Wrapping + +When you use the colon syntax to load a specific function (`./file.py:my_func`), strands-compose does something helpful: if the function isn't decorated with `@tool`, it auto-wraps it for you (and logs a warning). This is safe because the intent is unambiguous — you explicitly named the function: + +```yaml +tools: + - ./helpers.py:calculate_tax # Works even without @tool decorator +``` + +You'll see a warning in the logs: + +``` +WARNING | tool= | not decorated with @tool, wrapping automatically +``` + +For file/module-wide scanning (without `:`), only `@tool`-decorated functions are picked up. This prevents accidentally exposing internal helper functions. + +## Path Resolution + +Filesystem paths are resolved **relative to the config file**, not the working directory. This is critical — it means your config works regardless of where you run the Python script from: + +``` +project/ +├── config.yaml # tools: [./tools/analysis.py] +├── tools/ +│ └── analysis.py # Resolved relative to config.yaml's directory +└── main.py # Can be run from anywhere +``` + +Module-based specs (`module.path:func`) use the standard Python import system — the module must be importable. + +## Directory Scanning + +The directory spec (`./dir/`) recursively loads all `.py` files in the directory, skipping any file whose name starts with `_`: + +``` +tools/ +├── _helpers.py # Skipped (underscore prefix) +├── __init__.py # Skipped (underscore prefix) +├── analysis.py # Loaded — all @tool functions extracted +└── formatting.py # Loaded — all @tool functions extracted +``` + +> **Tips & Tricks** +> +> - Organize tools in a directory when you have many of them. One file per domain: `tools/math.py`, `tools/text.py`, `tools/database.py`. +> - The `strands_tools` package has built-in tools like `http_request`, `file_read`, `shell` — use them with `strands_tools:http_request`. +> - Each agent gets its own copy of tools. Two agents referencing the same file get independent tool instances. +> - Tool function docstrings are sent to the LLM as the tool description. Write good docstrings — they directly affect how well the model uses your tools. +> - Type hints on tool parameters become the JSON schema the LLM sees. Use `str`, `int`, `float`, `bool`, `list[str]`, etc. The more specific your types, the better the LLM calls your tools. + +--- + +[Next: Chapter 6 — Hooks →](Chapter_06.md) diff --git a/docs/configuration/Chapter_06.md b/docs/configuration/Chapter_06.md new file mode 100644 index 0000000..9cb0eec --- /dev/null +++ b/docs/configuration/Chapter_06.md @@ -0,0 +1,180 @@ +# Chapter 6: Hooks — Middleware for Agents + +[← Back to Table of Contents](README.md) | [← Previous: Tools](Chapter_05.md) + +--- + +Hooks are lifecycle callbacks that fire at specific points during agent execution — before/after invocations, before/after tool calls, before/after model calls. They're perfect for guardrails, logging, metrics, and custom behavior. + +```yaml +agents: + assistant: + model: default + hooks: + - type: strands_compose.hooks:MaxToolCallsGuard + params: + max_calls: 10 + - type: strands_compose.hooks:ToolNameSanitizer + - type: ./my_hooks.py:AuditLogger + params: + log_file: ./audit.log + system_prompt: "You are a helpful assistant." +``` + +## Hook Specification Formats + +Hooks can be specified in two ways: + +**Inline object** — with `type` and optional `params`: + +```yaml +hooks: + - type: strands_compose.hooks:MaxToolCallsGuard + params: + max_calls: 10 +``` + +**String shorthand** — just the import path (no params): + +```yaml +hooks: + - strands_compose.hooks:ToolNameSanitizer +``` + +Both the `type` field and the string shorthand accept: +- `module.path:ClassName` — for installed packages +- `./file.py:ClassName` — for local files (relative to config file) + +## Built-in Hooks + +strands-compose ships with three hooks: + +### `MaxToolCallsGuard` + +Limits how many tool calls an agent can make in a single invocation. Two-phase behavior: + +1. **First violation** — injects a system message telling the LLM to stop and write a final answer. +2. **Second violation** — if the LLM ignores the warning and calls another tool, the loop is terminated. + +```yaml +hooks: + - type: strands_compose.hooks:MaxToolCallsGuard + params: + max_calls: 15 +``` + +### `ToolNameSanitizer` + +Some models inject extra tokens into tool names (e.g., `search<|python_tag|>` instead of `search`). This hook strips those artifacts so strands can find the tool in the registry. + +```yaml +hooks: + - type: strands_compose.hooks:ToolNameSanitizer +``` + +No params needed — just add it. + +### `StopGuard` + +A cooperative stop mechanism — set a flag on the guard and the agent stops cleanly at the next opportunity. Useful for external cancellation (e.g., user disconnects from a web socket). + +`StopGuard` needs a Python callable for `stop_check`, so it's usually wired from Python rather than pure YAML: + +```python +from strands_compose.hooks import stop_guard_from_event + +guard, stop = stop_guard_from_event() + +# add `guard` to an agent's hooks, then later: +stop.set() +``` + +## Writing Custom Hooks + +A hook is any class that subclasses `strands.hooks.HookProvider` and implements `register_hooks()`: + +```python +# my_hooks.py +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import AfterToolCallEvent, AfterInvocationEvent + +class ToolCounter(HookProvider): + """Counts tool calls and prints a summary after each invocation.""" + + def __init__(self, verbose: bool = False): + self._count = 0 + self._verbose = verbose + + def register_hooks(self, registry: HookRegistry, **kwargs): + registry.add_callback(AfterToolCallEvent, self._on_tool) + registry.add_callback(AfterInvocationEvent, self._on_done) + + def _on_tool(self, event: AfterToolCallEvent): + self._count += 1 + if self._verbose: + print(f" Tool #{self._count}: {event.tool_use.get('name')}") + + def _on_done(self, event: AfterInvocationEvent): + print(f"Agent used {self._count} tools this turn.") + self._count = 0 +``` + +Use it in YAML: + +```yaml +hooks: + - type: ./my_hooks.py:ToolCounter + params: + verbose: true +``` + +The `params` dict is spread as `**kwargs` to your class constructor. + +## Hook Execution Order + +Hooks fire in the order they're listed. First hook's callbacks run before the second hook's for the same event. This matters when hooks interact — for example, `ToolNameSanitizer` should run before hooks that inspect tool names. + +## Available Hook Events + +These are the strands lifecycle events you can listen to: + +| Event | When It Fires | +|-------|---------------| +| `BeforeInvocationEvent` | Before the agent starts processing | +| `AfterInvocationEvent` | After the agent finishes | +| `BeforeModelCallEvent` | Before each LLM API call | +| `AfterModelCallEvent` | After each LLM API call | +| `BeforeToolCallEvent` | Before each tool execution | +| `AfterToolCallEvent` | After each tool execution | +| `BeforeNodeCallEvent` | Before a graph/swarm node executes | +| `AfterNodeCallEvent` | After a graph/swarm node executes | +| `BeforeMultiAgentInvocationEvent` | Before a multi-agent orchestration starts | +| `AfterMultiAgentInvocationEvent` | After a multi-agent orchestration completes | + +## Hooks on Orchestrations + +Orchestrations also support hooks — applied at the orchestration level, not the individual agent level: + +```yaml +orchestrations: + pipeline: + mode: graph + entry_name: writer + hooks: + - type: strands_compose.hooks:MaxToolCallsGuard + params: { max_calls: 30 } + edges: + - from: writer + to: reviewer +``` + +> **Tips & Tricks** +> +> - Each agent gets **fresh hook instances**. Two agents with the same hook config get independent instances — no shared state between them. +> - `params` preserves YAML types. `{ max_calls: 15 }` passes `max_calls` as an integer, `{ log_file: ./out.log }` as a string. +> - Hooks are the right place for cross-cutting concerns: rate limiting, audit logging, cost tracking, safety guardrails. +> - Combine `MaxToolCallsGuard` and `ToolNameSanitizer` as a baseline for any agent that uses tools — they handle the most common edge cases. + +--- + +[Next: Chapter 7 — Session Persistence →](Chapter_07.md) diff --git a/docs/configuration/Chapter_07.md b/docs/configuration/Chapter_07.md new file mode 100644 index 0000000..43568c2 --- /dev/null +++ b/docs/configuration/Chapter_07.md @@ -0,0 +1,162 @@ +# Chapter 7: Session Persistence — Memory That Survives Restarts + +[← Back to Table of Contents](README.md) | [← Previous: Hooks](Chapter_06.md) + +--- + +By default, agents are stateless — each `load()` call starts fresh. The `session_manager` section enables persistent conversation history. + +## Global Session Manager + +Define a session manager at the root level and **every agent** inherits it: + +```yaml +session_manager: + provider: file + params: + storage_dir: ./.sessions + session_id: my-session-001 + +agents: + assistant: + model: default + system_prompt: "You remember everything." + +entry: assistant +``` + +## Built-in Providers + +| Provider | Backend | Required Package | +|----------|---------|------------------| +| `file` | Local filesystem | *(included)* | +| `s3` | Amazon S3 bucket | *(included, needs AWS creds)* | +| `agentcore` | Bedrock AgentCore Memory | `pip install strands-compose[agentcore-memory]` | + +### File Provider + +```yaml +session_manager: + provider: file + params: + storage_dir: ./.sessions + session_id: my-session +``` + +Sessions are stored as files in `storage_dir`. Delete the directory to start fresh. + +### S3 Provider + +```yaml +session_manager: + provider: s3 + params: + bucket_name: my-agent-sessions + session_id: prod-session-001 +``` + +Requires AWS credentials in the environment. + +### AgentCore Provider + +The `agentcore` provider requires a unique `actor_id` per agent and **cannot** be set globally — set it per-agent instead: + +```yaml +agents: + assistant: + model: default + system_prompt: "You are helpful." + session_manager: + provider: agentcore + params: + actor_id: assistant + memory_id: my-memory-store +``` + +## Per-Agent Session Manager + +Any agent can override the global session manager with its own: + +```yaml +session_manager: + provider: file + params: + storage_dir: ./.sessions + +agents: + persistent_agent: + model: default + system_prompt: "I remember." + # Inherits the global file session manager + + stateless_agent: + model: default + system_prompt: "I forget." + session_manager: ~ # <-- Explicit opt-out with YAML null (~) +``` + +Setting `session_manager: ~` (YAML null) on an agent **explicitly opts it out** of the global default. This is important — without this, it would inherit the global one. + +## Session ID Resolution + +When no `session_id` is provided, strands-compose generates a random UUID — meaning each run gets a fresh session. The resolution order is: + +1. **Runtime override** — via `load_session(..., session_id="abc")` +2. **`params.session_id`** — from YAML config +3. **Random UUID** — fresh session per run + +## Custom Session Manager + +For anything beyond the built-in providers, point `type` to your own class: + +```yaml +session_manager: + type: my_package.sessions:RedisSessionManager + params: + host: localhost + port: 6379 +``` + +The class must be a subclass of `strands.session.SessionManager`. When `type` is set, `provider` is ignored. + +## Swarm Agents and Sessions + +**Important limitation**: agents that participate in a Swarm orchestration **cannot** have a session manager. This is a strands-agents limitation. If a global session manager is set and an agent is used in a swarm, strands-compose will raise a clear error: + +``` +ConfigurationError: Agent 'drafter' is in swarm orchestration and cannot +have a session manager (source: global 'session_manager:' in config). +Fix: Add 'session_manager: ~' to agent 'drafter' to opt out of the global default. +``` + +The fix: add `session_manager: ~` to each swarm agent to opt out. + +> **Tips & Tricks** +> +> - For development, `file` provider with a fixed `session_id` is great — restart your script and the agent remembers your conversation. +> - For server/API deployments, use `load_session()` with a per-request `session_id` to isolate conversations between users. See [the multi-tenant pattern](#the-multi-tenant-server-pattern) below. +> - Delete the `.sessions/` directory to "factory reset" your agent's memory. + +## The Multi-Tenant Server Pattern + +For web servers where each HTTP request needs its own session: + +```python +from strands_compose import load_config, resolve_infra, load_session + +# Once at startup +app_config = load_config("config.yaml") +infra = resolve_infra(app_config) +infra.mcp_lifecycle.start() + +# Per request +def handle_request(user_session_id: str, message: str): + resolved = load_session(app_config, infra, session_id=user_session_id) + return resolved.entry(message) +``` + +MCP servers are shared across sessions (started once), but agents and their conversation state are created fresh per session. + +--- + +[Next: Chapter 8 — Conversation Managers →](Chapter_08.md) diff --git a/docs/configuration/Chapter_08.md b/docs/configuration/Chapter_08.md new file mode 100644 index 0000000..a7e3985 --- /dev/null +++ b/docs/configuration/Chapter_08.md @@ -0,0 +1,71 @@ +# Chapter 8: Conversation Managers — Controlling Context Windows + +[← Back to Table of Contents](README.md) | [← Previous: Session Persistence](Chapter_07.md) + +--- + +Conversation managers control how the conversation history is managed — truncation, summarization, or no management at all. This is different from session persistence (which stores history) — conversation managers decide **what's in the context window** when the LLM is called. + +```yaml +agents: + assistant: + model: default + conversation_manager: + type: strands.agent:SlidingWindowConversationManager + params: + window_size: 40 + should_truncate_results: false + system_prompt: "You are a helpful assistant." +``` + +## Built-in Conversation Managers + +These come from strands-agents directly: + +| Class | What It Does | +|-------|-------------| +| `strands.agent:SlidingWindowConversationManager` | Keeps the last N messages in context | +| `strands.agent:SummarizingConversationManager` | Summarizes older messages to save context | +| `strands.agent:NullConversationManager` | No management — keeps entire history | + +## Using Them + +```yaml +# Fixed-window approach +conversation_manager: + type: strands.agent:SlidingWindowConversationManager + params: + window_size: 20 + +# Summarization approach +conversation_manager: + type: strands.agent:SummarizingConversationManager + params: + summary_ratio: 0.3 + preserve_recent_messages: 10 + +# No management (pass everything) +conversation_manager: + type: strands.agent:NullConversationManager +``` + +## Custom Conversation Managers + +Write your own by subclassing `strands.agent.conversation_manager.ConversationManager`: + +```yaml +conversation_manager: + type: ./managers.py:MySmartManager + params: + strategy: semantic_relevance +``` + +> **Tips & Tricks** +> +> - Conversation managers are per-agent. Different agents in the same config can use different strategies. +> - For orchestration coordinators that do a lot of delegating, `SlidingWindowConversationManager` with a generous `window_size` prevents context overflow from long delegate tool results. +> - Unlike session managers, there's no "global" conversation manager — set it on each agent that needs one. + +--- + +[Next: Chapter 9 — MCP →](Chapter_09.md) diff --git a/docs/configuration/Chapter_09.md b/docs/configuration/Chapter_09.md new file mode 100644 index 0000000..b513169 --- /dev/null +++ b/docs/configuration/Chapter_09.md @@ -0,0 +1,195 @@ +# Chapter 9: MCP — External Tool Servers + +[← Back to Table of Contents](README.md) | [← Previous: Conversation Managers](Chapter_08.md) + +--- + +The Model Context Protocol (MCP) lets agents connect to external tool servers. strands-compose supports three connection modes and manages the full server lifecycle. + +## Architecture + +``` +mcp_servers: → Define managed local servers (strands-compose starts/stops them) +mcp_clients: → Define connections to servers (local, remote, or subprocess) +agents: + my_agent: + mcp: [client_name] → Attach MCP clients as tool providers +``` + +## Mode 1: Managed Local Server + +You define a server, strands-compose starts it in a background thread before creating agents, and stops it on shutdown: + +```yaml +mcp_servers: + calculator: + type: ./server.py:create + params: + port: 9001 + +mcp_clients: + calc: + server: calculator # References the server above + params: + prefix: calc # Tools become calc_add, calc_multiply, etc. + +agents: + assistant: + mcp: [calc] + system_prompt: "Use calc tools for math." + +entry: assistant +``` + +The `type` field points to a factory function that returns an `MCPServer` instance: + +```python +# server.py +from mcp.server.fastmcp import FastMCP +from strands_compose.mcp import MCPServer + +class CalculatorServer(MCPServer): + def _register_tools(self, mcp: FastMCP) -> None: + @mcp.tool() + def add(a: float, b: float) -> float: + """Add two numbers.""" + return a + b + + @mcp.tool() + def multiply(a: float, b: float) -> float: + """Multiply two numbers.""" + return a * b + +def create(name: str, port: int = 9001) -> CalculatorServer: + return CalculatorServer(name=name, port=port) +``` + +The factory receives `name` (from the YAML key) plus everything in `params`. + +## Mode 2: Remote URL + +Connect to an existing MCP server over HTTP — no server management needed: + +```yaml +mcp_clients: + aws_docs: + url: https://knowledge-mcp.global.api.aws + transport: streamable-http + params: + prefix: aws + startup_timeout: 30 +``` + +## Mode 3: Stdio Subprocess + +Spawn a local process that speaks MCP over stdin/stdout: + +```yaml +mcp_clients: + filesystem: + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + params: + prefix: fs +``` + +## The `transport` Field + +Transport auto-detection usually works, but you can override it: + +| Transport | When to Use | +|-----------|-------------| +| `streamable-http` | Default for URLs and managed servers. Modern MCP transport. | +| `sse` | Older Server-Sent Events transport. Auto-detected if URL ends in `/sse`. | +| `stdio` | Set automatically for `command:` mode. Not valid for managed servers. | + +## Client `params` + +The `params` dict on an MCP client is forwarded to strands' `MCPClient` constructor: + +| Param | Type | What It Does | +|-------|------|-------------| +| `prefix` | string | Prefix all tool names from this server (e.g., `calc_add`) | +| `startup_timeout` | number | Seconds to wait for the server to respond | +| `tool_filters` | list | Filter which tools to expose | + +## Client `transport_options` + +Transport-specific options forwarded to the transport factory: + +```yaml +mcp_clients: + authenticated_server: + url: https://internal.example.com/mcp + transport_options: + headers: + Authorization: "Bearer ${API_TOKEN}" +``` + +Available options vary by transport: + +- **stdio**: `env`, `cwd`, `encoding` +- **sse**: `headers`, `timeout`, `sse_read_timeout` +- **streamable-http**: `headers`, `http_client`, `terminate_on_close` + +## Lifecycle Management + +strands-compose handles the startup ordering automatically: + +1. Start all MCP **servers** (in parallel) +2. Wait for all servers to be **ready** (TCP port check with configurable timeout) +3. Create agents (which auto-start MCP **clients**) + +On shutdown (via context manager or `.stop()`): + +1. Stop all **clients** first +2. Then stop all **servers** + +Always use the MCP lifecycle context manager: + +```python +resolved = load("config.yaml") + +with resolved.mcp_lifecycle: + result = resolved.entry("Hello!") +``` + +Or for async contexts: + +```python +async with resolved.mcp_lifecycle: + result = await resolved.entry.invoke_async("Hello!") +``` + +## MCPClientDef Validation + +Exactly **one** of `server`, `url`, or `command` must be set on each client. Setting zero or more than one raises a validation error: + +``` +MCPClientDef requires exactly one of 'server', 'url', or 'command'; got none. +``` + +## Combining Multiple MCP Sources + +A single agent can use tools from multiple MCP clients: + +```yaml +agents: + super_agent: + mcp: + - calc_client + - aws_knowledge + - filesystem + system_prompt: "You have math, AWS docs, and filesystem access." +``` + +> **Tips & Tricks** +> +> - The `prefix` parameter is your friend. It namespaces tools to avoid collisions: `calc_add` vs `aws_add`. +> - For development, managed servers (Mode 1) are the most convenient — everything starts and stops with your script. +> - For production, prefer remote URLs (Mode 2) — deploy MCP servers independently and connect agents to them. +> - Server transport defaults to `streamable-http`. You can also use `sse` for older MCP servers. +> - MCP servers support `server_params` which are forwarded to FastMCP constructor — useful for `stateless_http`, `json_response`, etc. + +--- + +[Next: Chapter 10 — Orchestrations →](Chapter_10.md) diff --git a/docs/configuration/Chapter_10.md b/docs/configuration/Chapter_10.md new file mode 100644 index 0000000..5fc2a80 --- /dev/null +++ b/docs/configuration/Chapter_10.md @@ -0,0 +1,170 @@ +# Chapter 10: Orchestrations — Multi-Agent Systems + +[← Back to Table of Contents](README.md) | [← Previous: MCP](Chapter_09.md) + +--- + +Orchestrations wire multiple agents into collaborative systems. Define them under the `orchestrations` section — each one has a `mode` and references agents by name. + +**Key rule**: agents and orchestrations share a single namespace. You can't have an agent named `team` and an orchestration named `team`. + +## Mode: Delegate + +A coordinator agent calls sub-agents as tool functions. Best for hub-and-spoke patterns where one agent directs others: + +```yaml +agents: + researcher: + model: default + system_prompt: "You research topics thoroughly." + + writer: + model: default + system_prompt: "You write polished articles." + + coordinator: + model: default + system_prompt: | + For every request: + 1. Call researcher to gather facts. + 2. Pass the facts to writer for the final article. + Delegate all work — don't write content yourself. + +orchestrations: + team: + mode: delegate + entry_name: coordinator + connections: + - agent: researcher + description: "Research a topic and return structured facts." + - agent: writer + description: "Write a polished article from research material." + +entry: team +``` + +**How it works**: strands-compose **forks** a new agent from the `entry_name` agent's blueprint (model, system_prompt, hooks, tools) and adds delegate tools for each connection. The original `coordinator` agent is **never mutated**. Each connection becomes an async tool that the coordinator can call. + +**Fields**: + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `mode` | `"delegate"` | Yes | | +| `entry_name` | string | Yes | The agent whose blueprint is forked as coordinator | +| `connections` | list | Yes | Sub-agents to wire as tools | +| `connections[].agent` | string | Yes | Name of the target agent or orchestration | +| `connections[].description` | string | Yes | Tool description the LLM sees | +| `session_manager` | dict | No | Override session manager for the forked agent | +| `hooks` | list | No | Additional hooks for the forked agent | +| `agent_kwargs` | dict | No | Override agent kwargs (merged with entry agent's kwargs) | + +## Mode: Swarm + +Peer agents hand off control to each other autonomously. No central coordinator — agents decide when to pass the baton: + +```yaml +agents: + drafter: + model: default + system_prompt: | + Write initial code. When done, hand off to reviewer. + + reviewer: + model: default + system_prompt: | + Review code. If issues found, hand back to drafter. + If good, hand off to tech_lead. + + tech_lead: + model: default + system_prompt: "Make final approval decision." + +orchestrations: + review_team: + mode: swarm + agents: [drafter, reviewer, tech_lead] + entry_name: drafter + max_handoffs: 10 + +entry: review_team +``` + +**How it works**: strands' Swarm injects a `handoff_to_agent` tool into every agent in the list. Agents call this tool to transfer control. Execution continues until one agent decides to stop or `max_handoffs` is reached. + +**Fields**: + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `mode` | `"swarm"` | Yes | | | +| `agents` | list[str] | Yes | | Agent names participating in the swarm | +| `entry_name` | string | Yes | | Which agent starts first | +| `max_handoffs` | int | No | 20 | Maximum handoffs before termination | +| `max_iterations` | int | No | 20 | Maximum iterations | +| `execution_timeout` | float | No | 900.0 | Total execution timeout (seconds) | +| `node_timeout` | float | No | 300.0 | Per-agent timeout (seconds) | +| `session_manager` | dict | No | | Swarm-level session manager | +| `hooks` | list | No | | Swarm-level hooks | + +**Limitation**: All swarm nodes must be plain agents — no nested orchestrations. Node agents cannot have session managers (see [Chapter 7](Chapter_07.md#swarm-agents-and-sessions)). + +## Mode: Graph + +Deterministic DAG pipeline with explicit edges. Agents execute in dependency order — independent nodes can run in parallel: + +```yaml +agents: + planner: + model: default + system_prompt: "Create a content outline." + + writer: + model: default + system_prompt: "Write content following the outline." + + editor: + model: default + system_prompt: "Edit for clarity and correctness." + +orchestrations: + pipeline: + mode: graph + entry_name: planner + edges: + - from: planner + to: writer + - from: writer + to: editor + +entry: pipeline +``` + +**How it works**: strands-compose feeds the edges to strands' `GraphBuilder`, which constructs a topological execution plan. The `entry_name` must be a node with no incoming edges (the pipeline start). + +**Fields**: + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `mode` | `"graph"` | Yes | | | +| `entry_name` | string | Yes | | Starting node (must have no incoming edges) | +| `edges` | list | Yes | | Edge definitions | +| `edges[].from` | string | Yes | | Source node name | +| `edges[].to` | string | Yes | | Target node name | +| `edges[].condition` | string | No | | Python callable for conditional routing | +| `max_node_executions` | int | No | | Max times any node can execute | +| `execution_timeout` | float | No | | Total pipeline timeout (seconds) | +| `node_timeout` | float | No | | Per-node timeout (seconds) | +| `reset_on_revisit` | bool | No | false | Reset agent state when a node is revisited | +| `session_manager` | dict | No | | Graph-level session manager | +| `hooks` | list | No | | Graph-level hooks | + +> **Tips & Tricks** +> +> - **Delegate** is best for "boss and workers" patterns — the coordinator has full control over when and how to call sub-agents. +> - **Swarm** is best for peer collaboration — agents negotiate among themselves. Great for review/revision cycles. +> - **Graph** is best for fixed pipelines — when you know the exact processing order. Parallel execution of independent nodes is automatic. +> - The `description` field on delegate connections is what the coordinator LLM sees as the tool description — make it clear and actionable. +> - In swarm mode, guide handoff behavior through system prompts — tell each agent *when* and *to whom* they should hand off. + +--- + +[Next: Chapter 11 — Graph Conditions →](Chapter_11.md) diff --git a/docs/configuration/Chapter_11.md b/docs/configuration/Chapter_11.md new file mode 100644 index 0000000..583113d --- /dev/null +++ b/docs/configuration/Chapter_11.md @@ -0,0 +1,67 @@ +# Chapter 11: Graph Conditions — Dynamic Routing + +[← Back to Table of Contents](README.md) | [← Previous: Orchestrations](Chapter_10.md) + +--- + +Graph edges can have conditions — Python functions that decide at runtime whether an edge should fire. This unlocks feedback loops and branching pipelines. + +```yaml +orchestrations: + pipeline: + mode: graph + entry_name: writer + reset_on_revisit: true + max_node_executions: 6 + edges: + - from: writer + to: reviewer + - from: reviewer + to: writer + condition: ./conditions.py:needs_revision + - from: reviewer + to: publisher + condition: ./conditions.py:is_approved +``` + +## Writing Condition Functions + +Condition functions receive the graph execution context and return `True` or `False`: + +```python +# conditions.py + +def needs_revision(context: dict) -> bool: + """Route back to writer if reviewer says REVISE.""" + last_output = str(context.get("last_output", "")) + return "REVISE" in last_output.upper() + +def is_approved(context: dict) -> bool: + """Route to publisher if reviewer approves.""" + last_output = str(context.get("last_output", "")) + return "APPROVED" in last_output.upper() +``` + +The condition spec format is the same as tools and hooks: `./file.py:function_name` or `module.path:function_name`. + +## Loops and Revisits + +When an edge condition creates a loop (reviewer → writer → reviewer), you need two settings: + +- **`reset_on_revisit: true`** — Resets the agent's conversation state when it's visited again. Without this, the agent accumulates context from all previous visits. +- **`max_node_executions: N`** — Safety cap on how many times any node can execute. Prevents infinite loops. + +## How Conditions Are Evaluated + +For a given node, all outgoing edges are checked. Edges without conditions always fire. Edges with conditions fire only if the function returns `True`. If no outgoing edge fires, the pipeline stops. + +> **Tips & Tricks** +> +> - Make your reviewer agent's output deterministic by instructing it to start with keywords like "REVISE:" or "APPROVED:" — this makes condition functions simple and reliable. +> - Always set `max_node_executions` when you have loops — it's your safety net against infinite cycles. +> - `reset_on_revisit` is usually what you want for revision loops — the writer should get fresh context each time, not accumulate all previous attempts. +> - Condition functions must be callable. If you accidentally point to a non-callable (like a string or class), strands-compose will raise a clear error. + +--- + +[Next: Chapter 12 — Nested Orchestrations →](Chapter_12.md) diff --git a/docs/configuration/Chapter_12.md b/docs/configuration/Chapter_12.md new file mode 100644 index 0000000..8d5953d --- /dev/null +++ b/docs/configuration/Chapter_12.md @@ -0,0 +1,118 @@ +# Chapter 12: Nested Orchestrations — Composing Systems + +[← Back to Table of Contents](README.md) | [← Previous: Graph Conditions](Chapter_11.md) + +--- + +Named orchestrations can reference each other. A swarm can be plugged into a delegate as a tool. A delegate can be a node in a graph. Compose them however you want. + +```yaml +agents: + researcher: + model: default + system_prompt: "Research topics thoroughly." + + reviewer: + model: default + system_prompt: "Review and improve content." + + qa_bot: + model: default + system_prompt: "Run quality checks on content." + + coordinator: + model: default + system_prompt: | + 1. Send work to content_team. + 2. Pass results to qa_bot. + 3. Return the final output. + +orchestrations: + # Inner orchestration — built first + content_team: + mode: swarm + agents: [researcher, reviewer] + entry_name: researcher + max_handoffs: 10 + + # Outer orchestration — references inner by name + manager: + mode: delegate + entry_name: coordinator + connections: + - agent: content_team # This is an orchestration, not a plain agent! + description: "Content production team." + - agent: qa_bot + description: "Quality assurance check." + +entry: manager +``` + +## How It Works + +1. strands-compose collects all orchestration dependencies. +2. It performs a **topological sort** — inner orchestrations are built before outer ones. +3. Built orchestrations become nodes in the node pool, available for outer orchestrations to reference. +4. For delegate mode, inner orchestrations are wrapped as async tools (just like regular agents). + +## Circular Dependencies + +Orchestrations that reference each other in a cycle are caught at load time: + +```yaml +orchestrations: + a: + mode: delegate + entry_name: some_agent + connections: + - agent: b + description: "..." + b: + mode: delegate + entry_name: other_agent + connections: + - agent: a # Circular! + description: "..." +``` + +``` +CircularDependencyError: Circular dependency between orchestrations: ['a', 'b']. +Orchestrations cannot reference each other in a cycle. +``` + +## Nesting Depth + +There's no hard limit on nesting depth. A delegate can reference a graph that contains a delegate that references a swarm. As long as there are no cycles, it builds. + +## Graph Nodes as Orchestrations + +Graph edges can reference orchestrations, not just agents. This means a graph node can be an entire swarm: + +```yaml +orchestrations: + writing_team: + mode: delegate + entry_name: writer + connections: + - agent: researcher + description: "Research first." + + pipeline: + mode: graph + entry_name: writing_team # Starts with the delegate orchestration + edges: + - from: writing_team + to: editor + - from: editor + to: publisher +``` + +> **Tips & Tricks** +> +> - Draw your system topology on paper first, then translate it to YAML. Each box is an agent or orchestration, each arrow is a connection/edge. +> - Name your orchestrations descriptively — `content_team`, `review_pipeline`, `analysis_graph` — because error messages reference these names. +> - Use delegate mode as the "outer shell" for most complex systems — it gives the coordinator explicit control over when to call sub-systems. + +--- + +[Next: Chapter 13 — Multi-File Configs →](Chapter_13.md) diff --git a/docs/configuration/Chapter_13.md b/docs/configuration/Chapter_13.md new file mode 100644 index 0000000..25b902d --- /dev/null +++ b/docs/configuration/Chapter_13.md @@ -0,0 +1,110 @@ +# Chapter 13: Multi-File Configs — Splitting and Merging + +[← Back to Table of Contents](README.md) | [← Previous: Nested Orchestrations](Chapter_12.md) + +--- + +Large configs can be split across multiple files. Pass a list to `load()` and they're merged: + +```python +from strands_compose import load + +resolved = load(["base.yaml", "agents.yaml", "mcp.yaml"]) +``` + +## Merge Rules + +**Collection sections** (dicts) are merged across files: + +- `models` — merged +- `agents` — merged +- `orchestrations` — merged +- `mcp_servers` — merged +- `mcp_clients` — merged + +**Singleton fields** use last-wins semantics: + +- `entry` — last file's value wins +- `session_manager` — last file's value wins +- `log_level` — last file's value wins +- `version` — last file's value wins + +## Duplicate Detection + +If two files define the same name in the same collection section, loading fails: + +```yaml +# file_a.yaml +agents: + helper: + system_prompt: "I help." + +# file_b.yaml +agents: + helper: # Duplicate! + system_prompt: "I also help." +``` + +``` +ValueError: Duplicate names in 'agents' across config sources: ['helper'] +``` + +## Per-File Variable Interpolation + +Each file's `vars` block is interpolated independently *before* merging. This means File A's vars don't affect File B: + +```yaml +# base.yaml +vars: + MODEL: us.anthropic.claude-sonnet-4-6-v1:0 +models: + default: + provider: bedrock + model_id: ${MODEL} # Resolves from base.yaml's vars + +# agents.yaml +vars: + TONE: friendly # This is agents.yaml's own vars +agents: + assistant: + model: default + system_prompt: "You are ${TONE}." +entry: assistant +``` + +## Typical Split Patterns + +**Infrastructure + Application**: +``` +base.yaml — vars, models, mcp_servers, mcp_clients, session_manager +agents.yaml — agents, orchestrations, entry +``` + +**Environment Layering**: +``` +base.yaml — shared models, shared agents +production.yaml — production model IDs, production entry +staging.yaml — staging model IDs, staging entry +``` + +**Team-Based**: +``` +models.yaml — all model definitions +research.yaml — researcher agents + orchestrations +content.yaml — writer/editor agents + orchestrations +main.yaml — coordinator agent, top-level orchestration, entry +``` + +## Neither File Needs to Be Complete + +Individual files don't need to be valid on their own. `base.yaml` can define models without agents or entry. `agents.yaml` can reference models it doesn't define. The merged result must be valid — individual files don't. + +> **Tips & Tricks** +> +> - Use multi-file configs when your single file exceeds ~200 lines. It makes diffs cleaner and team collaboration easier. +> - The `entry` field should typically go in the "application" file, not the "infrastructure" file — it's the most likely to change between use cases. +> - File paths for tools/hooks/servers are resolved relative to the file they appear in. If `agents.yaml` says `tools: [./tools.py]`, it looks for `tools.py` next to `agents.yaml`. + +--- + +[Next: Chapter 14 — Agent Factories →](Chapter_14.md) diff --git a/docs/configuration/Chapter_14.md b/docs/configuration/Chapter_14.md new file mode 100644 index 0000000..20b1fc5 --- /dev/null +++ b/docs/configuration/Chapter_14.md @@ -0,0 +1,97 @@ +# Chapter 14: Agent Factories — Custom Agent Construction + +[← Back to Table of Contents](README.md) | [← Previous: Multi-File Configs](Chapter_13.md) + +--- + +The `type` field on an agent lets you replace the standard `strands.Agent()` constructor with your own factory function. The `agent_kwargs` dict passes extra parameters to it. + +```yaml +agents: + assistant: + type: ./factory.py:create_agent + model: default + system_prompt: "You are a helpful assistant." + agent_kwargs: + greeting: "Ahoy, captain!" + personality: pirate +``` + +## Writing a Factory + +strands-compose calls your factory with all standard agent parameters plus `agent_kwargs`: + +```python +# factory.py +from strands import Agent + +def create_agent( + *, + name: str, + greeting: str = "Hello!", + personality: str = "friendly", + **kwargs, +) -> Agent: + """Custom factory that injects personality into the system prompt.""" + system_prompt = kwargs.pop("system_prompt", "") or "" + enhanced = f"{system_prompt}\nPersonality: {personality}. Greet with: {greeting}" + + return Agent( + name=name, + system_prompt=enhanced, + **kwargs, + ) +``` + +**Important**: `strands.Agent.__init__` does NOT accept `**kwargs` — it has explicit parameters only. Your factory **must** consume any custom keys from `agent_kwargs` before forwarding the rest to `Agent()`. + +## The `agent_kwargs` Dict + +`agent_kwargs` accepts any key-value pairs that get spread into your factory call. It can also pass valid `Agent()` parameters directly: + +```yaml +agents: + assistant: + model: default + system_prompt: "You are helpful." + agent_kwargs: + record_direct_tool_call: true + trace_attributes: + team: content + environment: production +``` + +When `type` is **not** set (standard agent construction), `agent_kwargs` is passed directly to `strands.Agent()`. Any invalid key will raise `TypeError` at construction time. + +## Delegate `agent_kwargs` Override + +Delegate orchestrations can also specify `agent_kwargs`, which get **merged** over the entry agent's kwargs (orchestration values win on conflict): + +```yaml +agents: + coordinator: + model: default + system_prompt: "Coordinate work." + agent_kwargs: + record_direct_tool_call: false + +orchestrations: + team: + mode: delegate + entry_name: coordinator + agent_kwargs: + record_direct_tool_call: true # Overrides the agent's value + connections: + - agent: worker + description: "Do the work." +``` + +> **Tips & Tricks** +> +> - Agent factories are great for custom `Agent` subclasses — your factory can return `MySpecialAgent(...)` which extends strands' `Agent`. +> - The factory must return a `strands.Agent` instance — strands-compose checks this and raises `TypeError` if it doesn't. +> - Use factory paths in the same format as hooks and tools: `./local/file.py:function_name` or `my_package.factory:create_agent`. + +--- + +[Next: Chapter 15 — Event Streaming →](Chapter_15.md) diff --git a/docs/configuration/Chapter_15.md b/docs/configuration/Chapter_15.md new file mode 100644 index 0000000..03e300a --- /dev/null +++ b/docs/configuration/Chapter_15.md @@ -0,0 +1,76 @@ +# Chapter 15: Event Streaming — Real-Time Observability + +[← Back to Table of Contents](README.md) | [← Previous: Agent Factories](Chapter_14.md) + +--- + +When you have nested orchestrations running, you need visibility into what's happening. `wire_event_queue()` attaches event publishers to every agent and orchestrator and funnels all events into a single async queue. + +```python +import asyncio +from strands_compose import AnsiRenderer, load + +async def main(): + resolved = load("config.yaml") + queue = resolved.wire_event_queue() + + async def invoke(): + try: + await resolved.entry.invoke_async("Analyze LLM trends.") + finally: + await queue.close() + + asyncio.create_task(invoke()) + + renderer = AnsiRenderer() + while (event := await queue.get()) is not None: + renderer.render(event) + renderer.flush() + +asyncio.run(main()) +``` + +## Event Types + +| Event Type | Description | +|------------|-------------| +| `AGENT_START` | Agent begins processing | +| `TOKEN` | Individual token streamed from LLM | +| `REASONING` | Reasoning/thinking content from LLM | +| `TOOL_START` | Tool execution begins | +| `TOOL_END` | Tool execution completes | +| `COMPLETE` | Agent finishes (with usage metrics) | +| `ERROR` | Model or execution error | +| `NODE_START` | Graph/swarm node begins | +| `NODE_STOP` | Graph/swarm node completes | +| `HANDOFF` | Swarm agent hands off to another | +| `MULTIAGENT_START` | Multi-agent orchestration begins | +| `MULTIAGENT_COMPLETE` | Multi-agent orchestration completes | + +## AnsiRenderer + +The built-in `AnsiRenderer` prints colored terminal output — agent names, tool calls, reasoning traces, tokens — all streaming live. Perfect for development and debugging. + +## Custom Event Consumers + +Events are `StreamEvent` dataclasses with `.asdict()` for serialization: + +```python +while (event := await queue.get()) is not None: + data = event.asdict() + # Send to websocket, log to file, push to metrics system... +``` + +## Configuring the Queue in YAML + +Event streaming is configured in Python, not YAML — it's a runtime concern. But the **hooks** it installs (`EventPublisher`) listen to the same lifecycle events as your YAML-defined hooks. They coexist peacefully. + +> **Tips & Tricks** +> +> - Call `wire_event_queue()` only **once** per `ResolvedConfig` — it mutates the agents by adding hooks. +> - Call `queue.flush()` between requests to clear stale events from a previous invocation. +> - The queue has a max size of 10,000. If your agent generates more events than the consumer processes, events are dropped with a warning. + +--- + +[Next: Chapter 16 — Name Sanitization →](Chapter_16.md) diff --git a/docs/configuration/Chapter_16.md b/docs/configuration/Chapter_16.md new file mode 100644 index 0000000..dcdf5e3 --- /dev/null +++ b/docs/configuration/Chapter_16.md @@ -0,0 +1,70 @@ +# Chapter 16: Name Sanitization — How Names Are Handled + +[← Back to Table of Contents](README.md) | [← Previous: Event Streaming](Chapter_15.md) + +--- + +Names in config (agent names, model names, MCP client/server names, orchestration names) follow strict rules: + +## Valid Names + +- Characters: `[a-zA-Z0-9_-]` +- Length: 1–64 characters +- Examples: `researcher`, `fast-model`, `content_writer_v2` + +## Automatic Sanitization + +If you use characters outside the valid set (spaces, dots, special characters), strands-compose sanitizes them: + +- Invalid characters → underscores +- Consecutive underscores → single underscore +- Leading/trailing underscores → stripped +- Names over 64 characters → truncated + +```yaml +agents: + "My Cool Agent!": # Sanitized to: My_Cool_Agent + system_prompt: "Hi." +``` + +A warning is logged when sanitization happens: + +``` +WARNING | section=, original=, sanitized= | sanitized collection key +``` + +## Reference Updates + +When a name is sanitized, **all references** to it throughout the config are updated automatically — `entry`, `model` references, `mcp` lists, orchestration agents, connections, and edges. + +## Namespace Collisions + +Agents and orchestrations share a single namespace. You cannot have: + +```yaml +agents: + team: + system_prompt: "I'm an agent." + +orchestrations: + team: # ERROR: collides with agent 'team' + mode: swarm + agents: [team] + entry_name: team +``` + +``` +ValueError: Name collision between agents and orchestrations: ['team']. +Names must be unique within each section. +``` + +Models, MCP servers, and MCP clients each have their own independent namespaces — a model and an agent can share a name (though it's confusing and not recommended). + +> **Tips & Tricks** +> +> - Stick to `snake_case` for names. It's valid, readable, and never needs sanitization. +> - Avoid naming an orchestration and an agent the same thing — even accidentally similar names can confuse you when debugging. + +--- + +[Next: Chapter 17 — The Loading Pipeline →](Chapter_17.md) diff --git a/docs/configuration/Chapter_17.md b/docs/configuration/Chapter_17.md new file mode 100644 index 0000000..a505f1d --- /dev/null +++ b/docs/configuration/Chapter_17.md @@ -0,0 +1,199 @@ +# Chapter 17: The Loading Pipeline — What Happens Under the Hood + +[← Back to Table of Contents](README.md) | [← Previous: Name Sanitization](Chapter_16.md) + +--- + +When you call `load("config.yaml")`, here's exactly what happens: + +## Step 1: Parse Sources + +Each config source (file path or raw YAML string) is parsed with `yaml.safe_load()`. `Path` objects are always treated as files. Strings are files if the path exists on disk, otherwise parsed as inline YAML. + +## Step 2: Strip Anchors and Interpolate Variables + +For each source independently: +1. Extract and remove the `vars` block +2. Strip `x-*` keys (YAML anchor scratch pads) +3. Interpolate `${VAR}` references using `vars` + environment + +## Step 3: Rewrite Relative Paths + +All filesystem-based specs (`./file.py:func`, `./tools/`) are rewritten to absolute paths anchored to the config file's directory. This ensures the config works regardless of the working directory. + +## Step 4: Sanitize Collection Keys + +Names in all collection sections are sanitized to `[a-zA-Z0-9_-]`. Cross-references are updated automatically. + +## Step 5: Merge (If Multi-File) + +Collection sections are combined, duplicate names detected, singleton fields use last-wins. + +## Step 6: Validate Against Schema + +The merged dict is validated against Pydantic models. Invalid fields, missing required values, wrong types — all caught here with clear error messages. + +## Step 7: Validate References + +Cross-references are checked: +- Agent `model` references → must exist in `models` +- Agent `mcp` references → must exist in `mcp_clients` +- MCP client `server` references → must exist in `mcp_servers` +- Orchestration agent references → must exist in `agents` or `orchestrations` + +## Step 8: Resolve Infrastructure + +Models, MCP servers, MCP clients, and session managers are created as Python objects. Nothing is started yet. + +## Step 9: Start MCP Lifecycle + +MCP servers are started in background threads. The pipeline waits for all servers to be ready (TCP port check). This happens **before** agent creation because `Agent.__init__` auto-starts MCP clients which need running servers. + +## Step 10: Create Agents + +Each agent definition is resolved: model looked up, tools loaded, hooks instantiated, MCP clients attached, session manager wired. Each agent is a fresh `strands.Agent` instance. + +## Step 11: Wire Orchestrations + +Orchestrations are topologically sorted and built in dependency order. Inner orchestrations first, outer orchestrations reference the already-built inner ones. + +## Step 12: Return ResolvedConfig + +The final `ResolvedConfig` has: +- `agents` — dict of all agents by name +- `orchestrators` — dict of all built orchestrations by name +- `entry` — the entry point (Agent, Swarm, or Graph) +- `mcp_lifecycle` — for managing shutdown + +## Advanced Topic: `load()` vs `load_config()` + `resolve_infra()` + `load_session()` + +Most users only need: + +```python +from strands_compose import load + +resolved = load("config.yaml") +``` + +That one call runs the whole pipeline: + +1. Parse YAML +2. Interpolate variables +3. Sanitize names +4. Merge files +5. Validate schema + references +6. Resolve infrastructure +7. Start MCP lifecycle +8. Create agents and orchestrations + +But strands-compose also exposes the lower-level split because **config parsing** and **session creation** are not always the same thing. + +### What counts as "config"? + +`load_config()` returns a validated `AppConfig` — just structured data. + +At this point, nothing is started and no live strands objects exist yet: + +- no `Agent` instances +- no orchestration objects +- no started MCP servers +- no connected MCP clients + +This step is useful when you want to parse and validate once at process startup, fail fast on bad YAML, and keep the validated config around. + +### What counts as "infrastructure"? + +`resolve_infra(app_config)` turns the validated config into the shared runtime pieces: + +- resolved model objects +- resolved MCP server objects +- resolved MCP client objects +- a cold `mcp_lifecycle` +- the global session manager (if configured) + +Important nuance: **resolved** does not mean **started**. + +After `resolve_infra()`: + +- MCP servers exist as Python objects, but are not running yet +- MCP clients exist as Python objects, but are not connected yet +- agents still do not exist +- orchestrations still do not exist + +You then start the shared MCP runtime explicitly: + +```python +from strands_compose.config import load_config, resolve_infra + +app_config = load_config("config.yaml") +infra = resolve_infra(app_config) +infra.mcp_lifecycle.start() +``` + +### What `load_session()` does + +`load_session(app_config, infra, session_id=...)` is the final step. It uses the already-started shared infrastructure to create a **fresh** `ResolvedConfig` for one session: + +- fresh agents +- fresh orchestrations +- fresh entry point +- the same shared MCP lifecycle + +This is the key distinction: + +- `resolve_infra()` gives you **shared process-level infrastructure** +- `load_session()` gives you **session-level agent graph built on top of that infrastructure** + +### Why this split matters for multi-tenant deployments + +In a multi-tenant server, you usually do **not** want to re-parse YAML, re-resolve models, or restart MCP servers on every request. Those are process-level concerns. + +Instead, you want: + +- one validated config shared by the process +- one resolved infrastructure shared by the process +- one started MCP lifecycle shared by the process +- one fresh set of agents per tenant/session/request + +Typical pattern: + +```python +from strands_compose.config import load_config, load_session, resolve_infra + +# Once at process startup +app_config = load_config("config.yaml") +infra = resolve_infra(app_config) +infra.mcp_lifecycle.start() + +# Per request / websocket / tenant session +resolved = load_session(app_config, infra, session_id="tenant-123") +result = resolved.entry("Hello!") +``` + +This avoids paying the startup cost repeatedly while still keeping per-session agent state isolated. + +### Session manager nuance + +There is one especially important detail in `load_session()`: + +- If you pass `session_id=...` **and** the config declares a global `session_manager`, strands-compose creates a **fresh session manager instance** for that session ID. +- If the config does **not** declare a global `session_manager`, `load_session()` does not invent one just because a `session_id` was provided. + +So `session_id` is an override for configured session persistence — not a standalone feature by itself. + +### Mental model + +Use this rule of thumb: + +- **`load()`** = convenience API for scripts and local apps +- **`load_config()`** = validate and freeze the declarative config +- **`resolve_infra()`** = build shared runtime dependencies, but do not start them yet +- **`load_session()`** = build one session's live agents/orchestrations from shared infra + +If you're building a CLI, a notebook, or a one-shot script, use `load()`. + +If you're building a long-running web server with many user sessions, use `load_config()` + `resolve_infra()` once, then `load_session()` for each session. + +--- + +[Next: Chapter 18 — Full Reference →](Chapter_18.md) diff --git a/docs/configuration/Chapter_18.md b/docs/configuration/Chapter_18.md new file mode 100644 index 0000000..37723e5 --- /dev/null +++ b/docs/configuration/Chapter_18.md @@ -0,0 +1,155 @@ +# Chapter 18: Full Reference — Every Field at a Glance + +[← Back to Table of Contents](README.md) | [← Previous: The Loading Pipeline](Chapter_17.md) + +--- + +## Root Config + +```yaml +version: "1" # Optional, defaults to "1" +vars: {} # Variable definitions (removed after interpolation) +models: {} # Named model definitions +agents: {} # Named agent definitions (required: at least one) +orchestrations: {} # Named orchestration definitions +mcp_servers: {} # Named MCP server definitions +mcp_clients: {} # Named MCP client connections +session_manager: {} # Global session manager +entry: "name" # Required: entry point agent or orchestration +log_level: "WARNING" # Optional: DEBUG, INFO, WARNING, ERROR +``` + +## ModelDef + +```yaml +models: + name: + provider: bedrock | openai | ollama | gemini | module.path:CustomModel + model_id: "model-identifier" + params: {} # Provider-specific kwargs +``` + +## AgentDef + +```yaml +agents: + name: + type: null # Custom factory: module.path:factory_func + agent_kwargs: {} # Extra kwargs for Agent() or custom factory + model: "model_name" # String ref to models: or inline ModelDef + system_prompt: "..." # System prompt string + description: "..." # Agent description (used in orchestration tools) + tools: [] # List of tool spec strings + hooks: [] # List of HookDef objects or import path strings + mcp: [] # List of MCP client names + tool_labels: {} # Tool name -> display label mapping + conversation_manager: null # ConversationManagerDef + session_manager: null # Per-agent SessionManagerDef (overrides global) +``` + +## HookDef + +```yaml +hooks: + # Inline object form + - type: module.path:ClassName # or ./file.py:ClassName + params: {} # Constructor kwargs + + # String shorthand (no params) + - module.path:ClassName +``` + +## SessionManagerDef + +```yaml +session_manager: + provider: file | s3 | agentcore # Built-in provider name + type: null # Custom class: module.path:ClassName (overrides provider) + params: {} # Constructor kwargs (session_id, storage_dir, etc.) +``` + +## ConversationManagerDef + +```yaml +conversation_manager: + type: strands.agent:SlidingWindowConversationManager + params: {} # Constructor kwargs (window_size, etc.) +``` + +## MCPServerDef + +```yaml +mcp_servers: + name: + type: ./server.py:create # Factory function: module.path:func or ./file.py:func + params: {} # Forwarded to factory (port, host, etc.) +``` + +## MCPClientDef + +```yaml +mcp_clients: + name: + # Exactly one of: + server: "server_name" # Reference to mcp_servers entry + url: "https://..." # External MCP server URL + command: ["cmd", "arg"] # Stdio subprocess command + + transport: null # Override: "streamable-http" | "sse" | "stdio" + params: {} # Forwarded to strands MCPClient (prefix, startup_timeout, etc.) + transport_options: {} # Transport-specific options (headers, timeout, etc.) +``` + +## DelegateOrchestrationDef + +```yaml +orchestrations: + name: + mode: delegate + entry_name: "agent_name" # Agent blueprint to fork + connections: + - agent: "target_name" # Agent or orchestration name + description: "..." # Tool description for LLM + session_manager: null # Override session manager + hooks: [] # Additional hooks + agent_kwargs: {} # Override agent kwargs (merged) +``` + +## SwarmOrchestrationDef + +```yaml +orchestrations: + name: + mode: swarm + agents: [agent1, agent2] # Participating agents + entry_name: "agent1" # Starting agent + max_handoffs: 20 # Max handoffs + max_iterations: 20 # Max iterations + execution_timeout: 900.0 # Total timeout (seconds) + node_timeout: 300.0 # Per-agent timeout (seconds) + session_manager: null # Swarm-level session manager + hooks: [] # Swarm-level hooks +``` + +## GraphOrchestrationDef + +```yaml +orchestrations: + name: + mode: graph + entry_name: "start_node" # Node with no incoming edges + edges: + - from: "node_a" + to: "node_b" + condition: null # Optional: ./file.py:func or module:func + max_node_executions: null # Safety cap for loops + execution_timeout: null # Total timeout (seconds) + node_timeout: null # Per-node timeout (seconds) + reset_on_revisit: false # Reset agent state on revisit + session_manager: null # Graph-level session manager + hooks: [] # Graph-level hooks +``` + +--- + +**Bonus**: [Quick Recipes →](Quick_Recipes.md) diff --git a/docs/configuration/Quick_Recipes.md b/docs/configuration/Quick_Recipes.md new file mode 100644 index 0000000..a2010d6 --- /dev/null +++ b/docs/configuration/Quick_Recipes.md @@ -0,0 +1,113 @@ +# Quick Recipes + +[← Back to Table of Contents](README.md) | [← Previous: Full Reference](Chapter_18.md) + +--- + +Copy-paste-ready configs for common patterns. + +## The Kitchen Sink + +Everything in one config: + +```yaml +vars: + MODEL: ${MODEL:-us.anthropic.claude-sonnet-4-6-v1:0} + TONE: ${TONE:-professional} + +x-hooks: &safety_hooks + - type: strands_compose.hooks:MaxToolCallsGuard + params: { max_calls: 20 } + - type: strands_compose.hooks:ToolNameSanitizer + +models: + default: + provider: bedrock + model_id: ${MODEL} + +session_manager: + provider: file + params: + storage_dir: ./.sessions + session_id: ${SESSION_ID:-default} + +agents: + researcher: + model: default + hooks: *safety_hooks + tools: + - ./tools/research.py + system_prompt: | + You are a ${TONE} assistant. + You specialize in research. + session_manager: ~ # Opt out (used in swarm) + + reviewer: + model: default + hooks: *safety_hooks + system_prompt: "You review content." + session_manager: ~ # Opt out (used in swarm) + + qa_bot: + model: default + hooks: *safety_hooks + system_prompt: "Run QA checks." + + coordinator: + model: default + hooks: *safety_hooks + conversation_manager: + type: strands.agent:SlidingWindowConversationManager + params: { window_size: 40 } + system_prompt: "Coordinate the team." + +orchestrations: + content_team: + mode: swarm + agents: [researcher, reviewer] + entry_name: researcher + max_handoffs: 10 + + pipeline: + mode: delegate + entry_name: coordinator + connections: + - agent: content_team + description: "Content production team." + - agent: qa_bot + description: "Quality assurance." + +entry: pipeline +log_level: ${LOG_LEVEL:-WARNING} +``` + +## Minimal Single Agent + +```yaml +agents: + bot: + system_prompt: "You answer questions." +entry: bot +``` + +## Two Models, One Agent + +```yaml +models: + fast: + provider: bedrock + model_id: us.anthropic.claude-sonnet-4-6-v1:0 + smart: + provider: openai + model_id: gpt-4o + +agents: + assistant: + model: ${WHICH_MODEL:-fast} + system_prompt: "You are helpful." +entry: assistant +``` + +--- + +[← Back to Table of Contents](README.md) diff --git a/docs/configuration/README.md b/docs/configuration/README.md new file mode 100644 index 0000000..d508788 --- /dev/null +++ b/docs/configuration/README.md @@ -0,0 +1,38 @@ +# YAML Configuration Guide + +**Everything you need to know about writing strands-compose YAML configs — from zero to production.** + +strands-compose lets you describe entire multi-agent systems in YAML and get back live, fully wired strands objects. This guide walks you through every configuration option, from the simplest one-agent setup to nested multi-orchestration systems with MCP servers, hooks, session persistence, conditional graph pipelines, and multi-file configs. + +No prior YAML expertise required. We start simple and build up. + +--- + +## Table of Contents + +1. [The Basics — Your First Config](Chapter_01.md) +2. [Models — Choosing Your LLM](Chapter_02.md) +3. [Variables — Environment-Driven Config](Chapter_03.md) +4. [YAML Anchors — DRY Config Blocks](Chapter_04.md) +5. [Tools — Giving Agents Superpowers](Chapter_05.md) +6. [Hooks — Middleware for Agents](Chapter_06.md) +7. [Session Persistence — Memory That Survives Restarts](Chapter_07.md) +8. [Conversation Managers — Controlling Context Windows](Chapter_08.md) +9. [MCP — External Tool Servers](Chapter_09.md) +10. [Orchestrations — Multi-Agent Systems](Chapter_10.md) +11. [Graph Conditions — Dynamic Routing](Chapter_11.md) +12. [Nested Orchestrations — Composing Systems](Chapter_12.md) +13. [Multi-File Configs — Splitting and Merging](Chapter_13.md) +14. [Agent Factories — Custom Agent Construction](Chapter_14.md) +15. [Event Streaming — Real-Time Observability](Chapter_15.md) +16. [Name Sanitization — How Names Are Handled](Chapter_16.md) +17. [The Loading Pipeline — What Happens Under the Hood](Chapter_17.md) +18. [Full Reference — Every Field at a Glance](Chapter_18.md) + +**Bonus**: [Quick Recipes](Quick_Recipes.md) — Copy-paste-ready configs for common patterns. + +--- + +That covers everything strands-compose YAML has to offer. When in doubt, check the [examples](../../examples/) — each one is a self-contained demo of the concepts above. And remember: after `load()`, what you get back are plain strands objects. No wrappers, no subclasses. Just the real deal, fully wired and ready to go. + +Happy composing! 🎼 diff --git a/docs/img/logo-black.svg b/docs/img/logo-black.svg new file mode 100644 index 0000000..0a7cb8b --- /dev/null +++ b/docs/img/logo-black.svg @@ -0,0 +1,213 @@ + + + + + + + + + + diff --git a/docs/img/logo-white.svg b/docs/img/logo-white.svg new file mode 100644 index 0000000..3673a4f --- /dev/null +++ b/docs/img/logo-white.svg @@ -0,0 +1,213 @@ + + + + + + + + + + diff --git a/docs/img/logo.png b/docs/img/logo.png new file mode 100644 index 0000000..c212edc Binary files /dev/null and b/docs/img/logo.png differ diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..7cd3dd1 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,67 @@ + + + + + + strands Compose + + + + +
+
+ strands-compose +
+
strands
+
Compose
+
+
+
+ + diff --git a/examples/01_minimal/README.md b/examples/01_minimal/README.md new file mode 100644 index 0000000..edf0ce2 --- /dev/null +++ b/examples/01_minimal/README.md @@ -0,0 +1,67 @@ +# 01 — Minimal Agent + +> The absolute minimum: one model, one agent, one YAML file — start chatting. + +## What this shows + +- `load()` — the single entry point to strands-compose. Give it a YAML file, get back a + ready-to-use agent. +- A minimal `config.yaml` with just a model and an agent — nothing else needed. +- `resolved.entry` — call it with a string and you get the agent's answer. No boilerplate. + +## How it works + +`load("config.yaml")` reads the file, creates a `BedrockModel` and a strands `Agent`, and +returns a `ResolvedConfig`. The `.entry` attribute is the agent you marked with `entry:` in +the YAML — you can call it directly like a function. + +```yaml +# config.yaml — this is all you need +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +agents: + assistant: + model: default + system_prompt: You are a concise and helpful assistant. + +entry: assistant +``` + +```python +from strands_compose import load + +resolved = load("config.yaml") +result = resolved.entry("What is Python?") +``` + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/01_minimal/main.py +``` + +## Try these prompts + +- `What is the capital of France?` +- `Explain what a Python generator is in one sentence.` +- `Write a haiku about software engineering.` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/01_minimal/config.yaml b/examples/01_minimal/config.yaml new file mode 100644 index 0000000..1968f14 --- /dev/null +++ b/examples/01_minimal/config.yaml @@ -0,0 +1,22 @@ +# 01_minimal — Hello, Agent +# +# The absolute minimum strands-compose config: +# a model, one agent, and an entry point. +# +# Run: +# uv run python examples/01_minimal/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +agents: + assistant: + model: default + system_prompt: | + You are a concise and helpful assistant. + Answer questions clearly and to the point. + If you don't know something, say so. + +entry: assistant diff --git a/examples/01_minimal/main.py b/examples/01_minimal/main.py new file mode 100644 index 0000000..52116ac --- /dev/null +++ b/examples/01_minimal/main.py @@ -0,0 +1,48 @@ +"""01_minimal — Hello, Agent. + +Load a single agent from config.yaml with load() and start an interactive REPL. + +Usage: + uv run python examples/01_minimal/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" + + +def main() -> None: + """Load agent from config.yaml and run an interactive REPL.""" + from strands_compose import load + + # Load the agent config + resolved = load(CONFIG) + agent = resolved.entry + + # ── Run ──────────────────────────────────────────────────────────────────── + print(f"\n{52 * '-'}") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nAgent is thinking...") + result = agent(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/02_vars_and_anchors/README.md b/examples/02_vars_and_anchors/README.md new file mode 100644 index 0000000..db29f81 --- /dev/null +++ b/examples/02_vars_and_anchors/README.md @@ -0,0 +1,104 @@ +# 02 — Variables & YAML Anchors + +> Keep your config DRY — use variables for environment-specific values, anchors to reuse blocks. + +## What this shows + +- **Variables** (`${VAR:-default}`) — swap model IDs, feature flags, or tones per environment + without editing the YAML. +- **YAML anchors** (`&name` / `*name`) — define a block once, reference it everywhere. + Unlike variables (always strings), anchors preserve types: integers, booleans, objects. +- How both patterns combine for clean, environment-agnostic configs. + +## How it works + +The `vars:` block declares variables with fallback defaults. strands-compose resolves them +from environment variables first, then falls back to the inline default. + +Anchors are plain YAML — you mark a block with `&anchor_name` and reference it with +`*anchor_name` anywhere in the same file. strands-compose doesn't do anything special here; +YAML handles the expansion. + +```yaml +vars: + MODEL: ${MODEL:-openai.gpt-oss-20b-1:0} # env var -> falls back to default + TONE: ${TONE:-friendly} + +x-base_prompt: &base_prompt | # define once + You are a ${TONE} assistant. + Keep answers clear and concise. + +x-model_params: &model_params + max_tokens: 512 # stays an integer, not "512" + +models: + default: + provider: bedrock + model_id: ${MODEL} + params: *model_params # reuse the block + +agents: + assistant: + model: default + system_prompt: *base_prompt # reuse the prompt + +entry: assistant +``` + +## Good to know + +**Variables are always strings.** `${MAX_TOKENS:-512}` becomes `"512"`. If you need an +integer or boolean, use an anchor instead. + +**Anchors are file-scoped.** An anchor defined in one YAML file can't be referenced from +another. For multi-file setups, see example 11. + +**`x-` prefix is a convention**, not a requirement. Keys starting with `x-` are ignored by +strands-compose validation, so they're a safe place to park anchor definitions. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +### Linux / macOS + +```bash +# Use built-in defaults +uv run python examples/02_vars_and_anchors/main.py + +# Override tone or model at runtime +TONE=formal uv run python examples/02_vars_and_anchors/main.py +MODEL=us.anthropic.claude-sonnet-4-6-v1:0 uv run python examples/02_vars_and_anchors/main.py +``` + +### Windows + +```cmd +REM Use built-in defaults +uv run python examples\02_vars_and_anchors\main.py + +REM Override tone or model at runtime +set TONE=formal +uv run python examples\02_vars_and_anchors\main.py +``` + +## Try these prompts + +- `What is the difference between a process and a thread?` +- `What year was Python first released?` +- `Explain what a Docker image is.` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/02_vars_and_anchors/config.yaml b/examples/02_vars_and_anchors/config.yaml new file mode 100644 index 0000000..9b4b7b8 --- /dev/null +++ b/examples/02_vars_and_anchors/config.yaml @@ -0,0 +1,48 @@ +# 02_vars_and_anchors — configuration +# +# Demonstrates two YAML reuse patterns: +# 1. Variables — ${VAR:-default} for environment-driven config +# 2. Anchors — &anchor / *anchor for DRY (Don't Repeat Yourself) blocks +# +# Try overriding at runtime: +# MODEL=us.anthropic.claude-sonnet-4-6-v1:0 uv run python examples/02_vars_and_anchors/main.py +# TONE=formal uv run python examples/02_vars_and_anchors/main.py + +vars: + # Read MODEL from env; fall back to a default when not set. + MODEL: ${MODEL:-openai.gpt-oss-20b-1:0} + + # Read TONE from env; controls the assistant's personality. + TONE: ${TONE:-friendly} + + # Read LOG_LEVEL from env; default to WARNING. + LOG_LEVEL: ${LOG_LEVEL:-WARNING} + +# Define a shared system prompt template using YAML anchors +# This avoids repeating the same boilerplate in multiple agents +x-base_prompt: &base_prompt | + You are a ${TONE} assistant. + Keep answers clear and concise. + Follow these principles: + - Be helpful and direct + - Use examples when needed + - Ask clarifying questions if unclear + +# Define shared model params using YAML anchors +# Note: integer values (max_tokens) are preserved as integers, unlike vars which become strings! +x-model_params: &model_params + max_tokens: 512 + +models: + default: + provider: bedrock + model_id: ${MODEL} + params: *model_params + +agents: + assistant: + model: default + system_prompt: *base_prompt # Alias: reference the anchor defined above + +entry: assistant +log_level: ${LOG_LEVEL} diff --git a/examples/02_vars_and_anchors/main.py b/examples/02_vars_and_anchors/main.py new file mode 100644 index 0000000..054d61b --- /dev/null +++ b/examples/02_vars_and_anchors/main.py @@ -0,0 +1,60 @@ +"""02_vars_and_anchors — Variables & YAML Anchors for DRY configuration. + +Demonstrates two YAML patterns for reducing duplication: + 1. Variables — ${VAR:-default} for environment-driven, deployment-specific values + 2. Anchors — &anchor / *alias for reusing YAML blocks without repetition + +Try overriding at runtime: + MODEL=us.anthropic.claude-sonnet-4-6-v1:0 uv run python examples/02_vars_and_anchors/main.py + TONE=formal uv run python examples/02_vars_and_anchors/main.py +""" + +from __future__ import annotations + +import os +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" + + +def main() -> None: + from strands_compose import load, load_config + + # ── Show the resolved var and anchor values before running ─────────────── + config = load_config(CONFIG) + print("\n[config.yaml — resolved variables & anchors]") + print(f" model : {config.models['default'].model_id}") + print(f" tone : {os.environ.get('TONE', 'friendly')}") + print(f" log_level : {config.log_level}") + # Anchors preserve types! max_tokens is an integer, not a string + max_tokens = config.models["default"].params.get("max_tokens") + if max_tokens: + print(f" max_tokens : {max_tokens} ({type(max_tokens).__name__})") + + # ── Run ──────────────────────────────────────────────────────────────────── + resolved = load(CONFIG) + agent = resolved.entry + print(f"\n{52 * '-'}") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nAgent is thinking...") + result = agent(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/03_tools/README.md b/examples/03_tools/README.md new file mode 100644 index 0000000..60d8749 --- /dev/null +++ b/examples/03_tools/README.md @@ -0,0 +1,78 @@ +# 03 — Python Tools + +> Give an agent Python functions as tools — YAML references your code directly. + +## What this shows + +The `tools:` key in an agent config lets you point at your Python code. +strands-compose loads it and registers matching functions with the agent. + +## How it works + +Add a `tools:` list to any agent definition. Each entry is a tool spec string — a path to +a file, a directory, or a Python module. + +```yaml +agents: + analyst: + tools: + - ./tools.py # all @tool functions from a file +``` + +Supported tool spec formats: + +| Format | What it loads | +|---|---| +| `./tools.py` | all `@tool`-decorated functions in the file | +| `./tools.py:count_words` | one specific function (decorator optional) | +| `./tools/` | all `.py` files in a directory (skips `_`-prefixed) | +| `my_module` | all `@tool` functions from an installed module | +| `my_module:my_func` | one specific function (decorator optional) | + +## Good to know + +**Decorate your tools with `@tool`.** +We recommend always using `@tool` from strands. The decorator tells strands-compose which +functions are tools and uses the docstring as the description the LLM sees. + +When you load a whole file or directory, only `@tool`-decorated functions are picked up — +plain functions are silently skipped. This is handy: you can keep helpers in the same file +without worrying about them leaking as tools. + +When you name a function explicitly with a colon (`./tools.py:my_func`), `@tool` is +optional — strands-compose will auto-wrap it for you (with a warning). But we still +recommend adding `@tool` for clarity and to make sure the docstring-based description +works as expected. + +**Paths are relative to the config file**, not the working directory. + +**Write clear docstrings** — the LLM uses them to decide when to call each tool. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/03_tools/main.py +``` + +## Try these prompts + +- `Count the words and characters in: "The quick brown fox jumps over the lazy dog"` +- `How many sentences does this paragraph have? "Alice was beginning to get very tired. She had nothing to do. Suddenly a white rabbit ran by."` +- `Reverse the words in: "Hello world from strands-compose"` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/03_tools/config.yaml b/examples/03_tools/config.yaml new file mode 100644 index 0000000..008a7ab --- /dev/null +++ b/examples/03_tools/config.yaml @@ -0,0 +1,35 @@ +# 03_tools — Python Tools +# +# Attach Python functions to an agent via the tools: list. +# Paths are resolved relative to this config file. +# +# Tool spec formats supported: +# ./tools.py -> all @tool-decorated functions in the file +# ./tools.py:func_name -> one specific function +# ./tools/ -> all .py files in a directory (skips _-prefixed) +# my_package -> all tools from an installed package +# my_package:func_name -> one specific function from a package +# +# Run: +# uv run python examples/03_tools/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +x-system_prompt: &system_prompt | + You are a text analysis assistant. + You have tools to count words, count characters, extract sentences, + and reverse word order in text. + Always use your tools when analysing text — never estimate by eye. + Report results clearly with the exact numbers from the tool output. + +agents: + analyst: + model: default + tools: + - ./tools.py + system_prompt: *system_prompt + +entry: analyst diff --git a/examples/03_tools/main.py b/examples/03_tools/main.py new file mode 100644 index 0000000..7e65356 --- /dev/null +++ b/examples/03_tools/main.py @@ -0,0 +1,49 @@ +"""03_tools — Python Tools. + +Load ``@tool``-decorated functions from an external file via tools: [./tools.py] +in config.yaml. strands-compose discovers every ``@tool``-decorated function +and registers it with the Agent automatically. Plain functions without the +decorator are ignored. + +Usage: + uv run python examples/03_tools/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = 'Count words and characters in: "The quick brown fox jumps over the lazy dog"' + + +def main() -> None: + from strands_compose import load + + resolved = load(CONFIG) + agent = resolved.entry + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nAgent is thinking...") + result = agent(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/03_tools/tools.py b/examples/03_tools/tools.py new file mode 100644 index 0000000..9d192fc --- /dev/null +++ b/examples/03_tools/tools.py @@ -0,0 +1,48 @@ +"""Text analysis tools for the 03_tools example. + +Functions must be decorated with ``@tool`` from strands so that +strands-compose can discover and register them with the agent. +""" + +from strands.tools.decorator import tool + + +@tool +def count_words(text: str) -> int: + """Count the number of words in the given text.""" + return len(text.split()) + + +@tool +def count_characters(text: str) -> int: + """Count characters in the given text, excluding spaces.""" + return len(text.replace(" ", "")) + + +@tool +def extract_sentences(text: str) -> list[str]: + """Split text into individual sentences. + + Args: + text: The text to split into sentences. + + Returns: + A list of sentence strings with leading/trailing whitespace removed. + """ + import re + + sentences = re.split(r"(?<=[.!?])\s+", text.strip()) + return [s for s in sentences if s] + + +@tool +def reverse_words(text: str) -> str: + """Return the text with the word order reversed. + + Args: + text: The text whose words will be reversed. + + Returns: + A new string with the same words in reverse order. + """ + return " ".join(reversed(text.split())) diff --git a/examples/04_session/README.md b/examples/04_session/README.md new file mode 100644 index 0000000..13692cb --- /dev/null +++ b/examples/04_session/README.md @@ -0,0 +1,144 @@ +# 04 — Persistent Memory (Session Manager) + +> Agents remember previous turns — conversations survive across restarts. + +## What this shows + +- `session_manager:` in YAML — persist conversation history to disk automatically +- Multi-turn memory: the agent recalls what you told it earlier in the same session +- Cross-restart memory: stop the process, start it again — the agent still knows you + +## How it works + +Add a `session_manager` block to your config. With `provider: file`, strands-compose saves +every turn to `.sessions/` on disk and restores it on the next `load()`. + +```yaml +session_manager: + provider: file + params: + storage_dir: ./.sessions + session_id: example-04 +``` + +That's it — no code changes needed. `load()` sees the session manager config, wires it into +the agent, and handles save/restore transparently. + +### Global vs inline session manager + +A **global** `session_manager` (top-level in config) is automatically assigned to **every +agent** that doesn't override it: + +```yaml +# Global — all agents get this by default +session_manager: + provider: file + params: + storage_dir: ./.sessions + +agents: + assistant: + model: default + system_prompt: You are helpful. + # ← gets the global file session manager automatically + + analyst: + model: default + system_prompt: You analyze data. + # ← also gets the global file session manager + + stateless_worker: + model: default + system_prompt: You do quick one-off tasks. + session_manager: ~ # ← explicitly opt out (null) +``` + +An **inline** `session_manager` on a specific agent overrides the global one: + +```yaml +session_manager: + provider: file + params: + storage_dir: ./.sessions + +agents: + assistant: + model: default + system_prompt: You are helpful. + # ← inherits global (file) + + special_agent: + model: default + system_prompt: You have your own memory. + session_manager: # ← inline override + provider: file + params: + storage_dir: ./.special_sessions +``` + +The resolution order in code is: +1. **Agent has inline `session_manager:`** -> use it (takes priority) +2. **Agent has `session_manager: ~`** -> opt out, no session manager +3. **Otherwise** -> inherit the global `session_manager` (or `None` if there's no global) + +## Good to know + +**This example pins `session_id: example-04`**, so conversations persist across restarts +automatically. If you remove `session_id`, each `load()` generates a random UUID and you +get a fresh session every time. + +```yaml +# Pinned — same session across restarts +session_id: example-04 + +# Omitted — random UUID, fresh session each run +# session_id: +``` + +**Delete `.sessions/` to start fresh.** The folder is created next to wherever you run the +command from. + +**Without `session_manager`, every call starts blank.** There is no implicit memory — you +opt in explicitly through config. + +**Swarm agents can't have a session manager.** If an agent is used inside a Swarm +orchestration, strands doesn't support session persistence for it. Use `session_manager: ~` +to opt that agent out of the global default, or you'll get an error at load time. + +> [!WARNING] +> **Swarm + session manager is not yet supported.** +> Strands Agents does not currently allow session persistence for agents inside a Swarm +> orchestration. If you have a global `session_manager:`, any agent used in a Swarm must +> explicitly opt out with `session_manager: ~`. This restriction may be lifted in a future +> version of strands-agents. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/04_session/main.py +``` + +## Try these prompts + +Run the example and type in order: + +1. `My name is Alice and I work as a data engineer.` +2. `What do you know about me so far?` +3. Exit and run again — the agent should still remember your name. + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/04_session/config.yaml b/examples/04_session/config.yaml new file mode 100644 index 0000000..4d6d6f2 --- /dev/null +++ b/examples/04_session/config.yaml @@ -0,0 +1,32 @@ +# 04_session — Persistent Memory +# +# The session_manager persists conversation history to disk. +# The agent remembers everything you said across turns — and across restarts. +# +# Run: +# uv run python examples/04_session/main.py +# +# Sessions are stored in .sessions/ (relative to where you run the command). +# Delete that folder to start fresh. + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +session_manager: + provider: file + params: + storage_dir: ./.sessions + session_id: example-04 + +agents: + assistant: + model: default + system_prompt: | + You are a personal assistant with memory. + Remember everything the user tells you about themselves — name, job, + preferences, ongoing projects — and refer back to it naturally. + If you have no prior context about the user, ask for their name first. + +entry: assistant diff --git a/examples/04_session/main.py b/examples/04_session/main.py new file mode 100644 index 0000000..e1cd5dd --- /dev/null +++ b/examples/04_session/main.py @@ -0,0 +1,48 @@ +"""04_session — Persistent Memory. + +Declare a session_manager in config.yaml — strands-compose wires persistent +conversation history automatically. State is saved to .sessions/ on disk. + +Usage: + uv run python examples/04_session/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = "My name is Alice and I work as a data engineer." + + +def main() -> None: + from strands_compose import load + + resolved = load(CONFIG) + agent = resolved.entry + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print("The agent remembers everything you say — even across restarts.") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nAgent is thinking...") + result = agent(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/05_hooks/README.md b/examples/05_hooks/README.md new file mode 100644 index 0000000..732fdcc --- /dev/null +++ b/examples/05_hooks/README.md @@ -0,0 +1,84 @@ +# 05 — Hooks + +> Attach middleware to every agent invocation — custom or built-in. + +## What this shows + +- How to write a **custom hook** (`FingerprintHook`) that counts tool calls and prints a summary at the end of each invocation +- How to wire it in `config.yaml` alongside built-in hooks — no Python glue code needed +- `MaxToolCallsGuard` — prevents Agent from infinite loop of tool calling. Sets max allowed calls per invocation. +- `ToolNameSanitizer` — some models inject extra tokens into tool names. This hook strips them so the agent can call tools correctly + +## How it works + +Hooks are Python classes that implement the strands `HookProvider` interface. No decorator — just a `register_hooks(self, registry)` method: + +```python +class FingerprintHook(HookProvider): + def __init__(self) -> None: + self._tool_calls = 0 + + def register_hooks(self, registry: HookRegistry, **kwargs) -> None: + registry.add_callback(AfterToolCallEvent, self._on_after_tool) + registry.add_callback(AfterInvocationEvent, self._on_after_invocation) + + def _on_after_tool(self, event: AfterToolCallEvent) -> None: + self._tool_calls += 1 + + def _on_after_invocation(self, event: AfterInvocationEvent) -> None: + print(f">>> THIS IS YOUR CUSTOM HOOK: Agent used {self._tool_calls} tools <<<") + self._tool_calls = 0 # reset for the next turn +``` + +In `config.yaml`, hooks are listed under the agent. They fire in order: + +```yaml +hooks: + - type: ./hooks.py:FingerprintHook # custom — from local file + - type: strands_compose.hooks:MaxToolCallsGuard + params: + max_calls: 5 + - type: strands_compose.hooks:ToolNameSanitizer +``` + +The spec format for hooks is always `module_or_file:ClassName` — the class name is required (no bulk scan). + +## Good to know + +- `BeforeToolCallEvent` and `AfterToolCallEvent` are the most common hook points. See strands docs for the full list. +- Multiple hooks on the same agent compose in declaration order. Each fires independently. +- `MaxToolCallsGuard` uses a two-phase approach: the first over-limit call cancels the tool and instructs the LLM to write a final answer (the loop continues so it gets one more turn). If the LLM ignores that and requests another tool, the event loop is hard-stopped. A `WARNING` is logged on both phases. +- `ToolNameSanitizer` is often necessary — some models append extra tokens to tool names (e.g. `search<|x|>` instead of `search`), causing strands to silently fail the tool lookup. The hook strips the artifacts before strands resolves the name. +- The tools in `custom_tools.py` are mock stubs that return deterministic fake data so the example works without external APIs. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/05_hooks/main.py +``` + +## Try these prompts + +At the end you'll see our FingerprintHook log: +`>>> THIS IS YOUR CUSTOM HOOK: Agent used N tools <<<` + +- `Research the impact of electric vehicles on city air quality. Be thorough.` +- `Find facts about Python programming and write a short summary.` +- `What do we know about climate change and renewable energy?` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/05_hooks/config.yaml b/examples/05_hooks/config.yaml new file mode 100644 index 0000000..35eaf13 --- /dev/null +++ b/examples/05_hooks/config.yaml @@ -0,0 +1,40 @@ +# 05_hooks — Hooks +# +# Hooks are middleware attached to each agent that fire at lifecycle events. +# They are declared under each agent; multiple hooks compose in order. +# +# Run: +# uv run python examples/05_hooks/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +agents: + researcher: + model: default + tools: + - ./custom_tools.py + hooks: + # Two-phase tool-call cap: first violation tells the LLM to stop and write + # a final answer; if it ignores that and calls another tool, the loop is terminated. + - type: strands_compose.hooks:MaxToolCallsGuard + params: + max_calls: 5 + + # Some models inject extra tokens into tool names (e.g. "search<|x|>" instead of "search"). + # Strands can't look up the tool when that happens — this hook strips the artifacts. + - type: strands_compose.hooks:ToolNameSanitizer + + # Custom hook — counts tool calls and prints a summary after each invocation. + # Shows how to write and wire your own HookProvider from a local file. + - type: ./hooks.py:FingerprintHook + + system_prompt: | + You are a research assistant. + When asked about a topic, use your search and facts tools to gather + information before writing your answer. + Try to make multiple tool calls to cover different angles. + +entry: researcher diff --git a/examples/05_hooks/custom_tools.py b/examples/05_hooks/custom_tools.py new file mode 100644 index 0000000..16a543c --- /dev/null +++ b/examples/05_hooks/custom_tools.py @@ -0,0 +1,50 @@ +"""Mock research tools for the 05_hooks example. + +These return deterministic mock data so the example works without +any real external API keys. The agent can call them multiple times +to trigger MaxToolCallsGuard. +""" + +from strands.tools.decorator import tool + + +@tool +def search(query: str) -> str: + """Search the web for information about a topic. + + Args: + query: The search query string. + + Returns: + A string of mock search results. + """ + snippets = [ + f"Study shows {query} has significant measurable impact on outcomes.", + f"Recent analysis of {query} reveals three key trends for 2025.", + f"Experts debate best practices around {query} — consensus emerging.", + f"New data on {query} contradicts earlier assumptions from 2020.", + ] + # Pick two snippets at a query-derived index so different queries return different results. + idx = sum(query.encode()) % len(snippets) + picked = [snippets[idx], snippets[(idx + 1) % len(snippets)]] + return f"[Search: {query!r}]\n" + "\n".join(picked) + + +@tool +def get_facts(topic: str) -> str: + """Retrieve key facts about a topic. + + Args: + topic: The topic to retrieve facts for. + + Returns: + A string listing mock facts about the topic. + """ + return ( + f"[Facts: {topic!r}]\n" + f"• Fact 1: {topic} was first studied in the early 20th century.\n" + f"• Fact 2: Over 1,000 peer-reviewed papers mention {topic} annually.\n" + f"• Fact 3: Industries most affected by {topic} spend $2B+ on research.\n" + f"• Fact 4: Public awareness of {topic} increased 40% since 2020.\n" + f"• Fact 5: Three countries lead global investment in {topic} research." + ) diff --git a/examples/05_hooks/hooks.py b/examples/05_hooks/hooks.py new file mode 100644 index 0000000..b25f0d0 --- /dev/null +++ b/examples/05_hooks/hooks.py @@ -0,0 +1,36 @@ +"""Custom hooks for the 05_hooks example. + +Shows how to write your own HookProvider subclass. +Hooks are class-based — no decorator needed, just implement register_hooks(). +""" + +from __future__ import annotations + +from typing import Any + +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import AfterInvocationEvent, AfterToolCallEvent + + +class FingerprintHook(HookProvider): + """Counts tool calls and prints a summary at the end of each invocation. + + Drop-in example of a custom hook — use this as a template for logging, + metrics, or any side-effect you want to attach to the agent lifecycle. + """ + + def __init__(self) -> None: + self._tool_calls: int = 0 + + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + """Wire callbacks to the events we care about.""" + registry.add_callback(AfterToolCallEvent, self._on_after_tool) + registry.add_callback(AfterInvocationEvent, self._on_after_invocation) + + def _on_after_tool(self, event: AfterToolCallEvent) -> None: + self._tool_calls += 1 + + def _on_after_invocation(self, event: AfterInvocationEvent) -> None: + msg = f"Agent '{event.agent.name}' used {self._tool_calls} tools in this turn." + print(f"\n\n\033[32m>>> CUSTOM HOOK: {msg} <<<\033[0m\n") + self._tool_calls = 0 # reset for the next turn diff --git a/examples/05_hooks/main.py b/examples/05_hooks/main.py new file mode 100644 index 0000000..28e2b33 --- /dev/null +++ b/examples/05_hooks/main.py @@ -0,0 +1,51 @@ +"""05_hooks — Hooks. + +Demonstrates a custom FingerprintHook, MaxToolCallsGuard, and ToolNameSanitizer +(strips model-injected artifacts from tool names), all declared inline in config.yaml. + +Usage: + uv run python examples/05_hooks/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = "Research the impact of electric vehicles on city air quality. Be thorough." + + +def main() -> None: + from strands_compose import load + + resolved = load(CONFIG) + agent = resolved.entry + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print( + "Watch for the CUSTOM HOOK summary line after each response — that's FingerprintHook counting tool calls." + ) + print("MaxToolCallsGuard will stop the agent after 5 tool calls.") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nAgent is thinking...") + result = agent(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/06_mcp/README.md b/examples/06_mcp/README.md new file mode 100644 index 0000000..1a8f195 --- /dev/null +++ b/examples/06_mcp/README.md @@ -0,0 +1,126 @@ +# 06 — MCP: All Connection Modes + +> One example that covers every way to wire MCP tools to an agent. + +## What this shows + +| Mode | Key | What it does | +|---|---|---| +| 1 | `server:` | Launch a local Python MCP server; strands-compose owns its full lifecycle | +| 2 | `url:` | Connect to a real external MCP server over Streamable HTTP — no server setup | +| 3 | `command:` *(commented)* | Spawn a local CLI tool that speaks MCP over stdio | + +Both live clients are attached to a **single agent**, which gets calculator tools +from the local server and AWS documentation tools from the remote server. + +## How it works + +### Mode 1 — local managed server + +```yaml +mcp_servers: + calculator: + type: ./server.py:create # factory function -> MCPServer subclass + params: + port: 9001 + +mcp_clients: + calc_client: + server: calculator # auto-connects; transport/URL inferred + params: + prefix: calc # tools: calc_add, calc_multiply, calc_percentage +``` + +`server.py` subclasses `MCPServer` and uses FastMCP's `@mcp.tool()` decorator. +The `create()` factory is called by strands-compose with `params` from YAML. +On `load()`, strands-compose starts the server, connects the client, and on exit +`mcp_lifecycle.stop()` tears everything down — you never manage threads or sockets. + +### Mode 2 — real external HTTP server + +```yaml +mcp_clients: + aws_knowledge: + url: https://knowledge-mcp.global.api.aws + transport: streamable-http # auto-detected from URL if omitted + params: + prefix: aws # tools: aws_search, aws_read_doc, … + startup_timeout: 30 +``` + +AWS publicly hosts a Knowledge MCP server at `https://knowledge-mcp.global.api.aws`. +No API key is needed. No `mcp_servers:` block — the server is already running. + +### Mode 3 — stdio subprocess *(uncomment in config to try)* + +```yaml +mcp_clients: + fs_tools: + command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + params: + prefix: fs +``` + +For CLI tools that speak MCP over stdin/stdout. Works with any `npx`, `uvx`, +or binary that implements the MCP stdio protocol. + +### Attaching both clients to one agent + +```yaml +agents: + assistant: + mcp: + - calc_client + - aws_knowledge +``` + +The agent sees `calc_*` and `aws_*` tools simultaneously and picks the right one +based on the question. + +## Good to know + +**`mcp_servers:` is only needed for locally managed servers.** For `url:` or +`command:` clients you connect to servers you don't own — no `mcp_servers:` block. + +**`params.prefix`** namespaces all tool names from a client — avoids collisions +when two servers expose identically named tools. + +**`params.tool_filters`** limits which tools are visible to the agent — useful +for large servers where you only need a few tools. + +**Transport auto-detection.** `url:` clients infer the transport from the URL +scheme and path. Override with `transport:` if needed. + +**Paths** in `type:` are relative to the config file, not the working directory. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) for the Bedrock model +- Dependencies installed: `uv sync` +- No extra credentials needed for the AWS Knowledge MCP endpoint + +## Run + +```bash +uv run python examples/06_mcp/main.py +``` + +## Try these prompts + +- `What is 15% of 240? Also, what is Amazon S3?` +- `Add 47 and 89, then multiply the result by 3.` +- `What IAM permissions do I need to read objects from an S3 bucket?` +- `I have a budget of 1200. Allocate 35% to marketing. How much is that?` +- `Explain the difference between Amazon RDS and Amazon Aurora.` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/06_mcp/config.yaml b/examples/06_mcp/config.yaml new file mode 100644 index 0000000..331b04f --- /dev/null +++ b/examples/06_mcp/config.yaml @@ -0,0 +1,68 @@ +# 06_mcp — MCP: All Connection Modes in One Example +# +# Shows all three ways to wire MCP tools to an agent: +# +# 1. mcp_servers: + server: — launch a local Python server (managed lifecycle) +# 2. url: — connect to a real external MCP server over HTTP +# 3. command: (commented) — spawn a stdio subprocess that speaks MCP +# +# Both live clients are attached to a single agent, giving it calculator tools +# from the local server AND AWS documentation tools from the remote server. +# +# Run: +# uv run python examples/06_mcp/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +# ── Local server (managed lifecycle) ───────────────────────────────────────── +# strands-compose calls create(**params) -> starts the server before load() returns. +mcp_servers: + calculator: + type: ./server.py:create + params: + port: 9001 + +# ── Clients ─────────────────────────────────────────────────────────────────── +mcp_clients: + + # Mode 1 — server: reference — connects to an mcp_servers entry above + calc_client: + server: calculator + params: + prefix: calc # tools: calc_add, calc_multiply, calc_percentage + + # Mode 2 — url: — connects to a real external server over Streamable HTTP. + # AWS publicly hosts a Knowledge MCP server; no API key required. + aws_knowledge: + url: https://knowledge-mcp.global.api.aws + transport: streamable-http + params: + prefix: aws # tools: aws_search, aws_read_doc, … + startup_timeout: 30 + + # Mode 3 — command: — spawn a local CLI tool that speaks MCP over stdio. + # (uncomment to try; requires npx) + # fs_tools: + # command: ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/tmp"] + # params: + # prefix: fs + +# ── Agent ───────────────────────────────────────────────────────────────────── +# Both clients are attached — the agent gets calc_* AND aws_* tools. +agents: + assistant: + model: default + mcp: + - calc_client + - aws_knowledge + system_prompt: | + You are a helpful assistant with two sets of tools: + - calc_* — arithmetic (add, multiply, percentage). Always use them for maths. + - aws_* — live AWS documentation search. Use them for any AWS questions. + Cite the documentation source when answering AWS questions. + Never calculate by hand when a calc tool is available. + +entry: assistant diff --git a/examples/06_mcp/main.py b/examples/06_mcp/main.py new file mode 100644 index 0000000..b99bd69 --- /dev/null +++ b/examples/06_mcp/main.py @@ -0,0 +1,50 @@ +"""06_mcp — MCP: All Connection Modes in One Example. + +Demonstrates all three MCP client connection modes in a single agent: + - server: local Python MCP server (managed lifecycle via mcp_servers) + - url: real external HTTP server (AWS Knowledge MCP, no API key needed) + - command: stdio subprocess (shown in config comments) + +Usage: + uv run python examples/06_mcp/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = "What is 15% of 240? Also, what is Amazon S3?" + + +def main() -> None: + from strands_compose import load + + resolved = load(CONFIG) + agent = resolved.entry + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print("Tools: calc_add/multiply/percentage (local MCP) + aws_* (AWS Knowledge MCP).") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nAgent is thinking...") + result = agent(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/06_mcp/server.py b/examples/06_mcp/server.py new file mode 100644 index 0000000..795f20f --- /dev/null +++ b/examples/06_mcp/server.py @@ -0,0 +1,75 @@ +"""Custom MCP server for the 06_mcp example. + +Subclass MCPServer and implement _register_tools(mcp) to expose +any Python functions as MCP tools. The create() factory at the +bottom is called by strands-compose with the params from YAML. +""" + +from __future__ import annotations + +from mcp.server.fastmcp import FastMCP + +from strands_compose.mcp import MCPServer + + +class CalculatorServer(MCPServer): + """A simple arithmetic tool server that runs as a background HTTP process.""" + + def _register_tools(self, mcp: FastMCP) -> None: + """Register all tools with the FastMCP instance. + + This method is called once when the server starts. + Use FastMCP's @mcp.tool() decorator to expose functions. + """ + + @mcp.tool() + def add(a: float, b: float) -> float: + """Add two numbers together. + + Args: + a: The first operand. + b: The second operand. + + Returns: + The sum of a and b. + """ + return a + b + + @mcp.tool() + def multiply(a: float, b: float) -> float: + """Multiply two numbers together. + + Args: + a: The first factor. + b: The second factor. + + Returns: + The product of a and b. + """ + return a * b + + @mcp.tool() + def percentage(value: float, percent: float) -> float: + """Calculate what percent% of value is. + + Args: + value: The base value. + percent: The percentage to calculate (e.g. 30 means 30%). + + Returns: + The result of value * percent / 100. + """ + return value * percent / 100 + + +def create(name: str = "calculator", port: int = 9001) -> CalculatorServer: + """Factory called by strands-compose with params from YAML. + + Args: + name: Server name assigned by strands-compose (from the YAML key). + port: The TCP port the MCP server will listen on. + + Returns: + A configured CalculatorServer instance (not yet started). + """ + return CalculatorServer(name=name, port=port) diff --git a/examples/07_delegate/README.md b/examples/07_delegate/README.md new file mode 100644 index 0000000..d812ec3 --- /dev/null +++ b/examples/07_delegate/README.md @@ -0,0 +1,86 @@ +# 07 — Delegate (Multi-Agent Coordination) + +> One agent calls others as tools — research, write, done. + +## What this shows + +- `mode: delegate` in `orchestrations:` — turns sub-agents into callable tools +- `entry_name:` — explicitly names the coordinator agent whose blueprint is used +- `connections:` — a flat list of child agents, each with a `description:` the + coordinator sees when deciding which tool to call +- The simplest multi-agent pattern: a coordinator that delegates, never writes itself + +## How it works + +Define your agents normally, then wire them with an orchestration: + +```yaml +orchestrations: + main: + mode: delegate + entry_name: coordinator + connections: + - agent: researcher + description: "Research a topic and return structured facts." + - agent: writer + description: "Write a report from the provided facts." +``` + +`entry:` points at the orchestration name (`main`), which is all you need in `main.py`. + +When `load()` resolves the orchestration, it **forks a brand-new Agent** from the +`coordinator` blueprint (model, system prompt, hooks, kwargs — everything). `researcher` +and `writer` are each wrapped as async `@tool` functions and added to that new agent. +The original `coordinator` agent defined under `agents:` is never mutated. + +``` +coordinator blueprint + └── forked into new Agent + ├── researcher (tool) — gathers facts + └── writer (tool) — turns facts into a report +``` + +The forked agent's `agent_id` is the orchestration name (`main`), making it easy to +trace in logs. Any `agent_kwargs` defined on the orchestration are merged on top of +the coordinator's own kwargs — orchestration values win on conflict. + +## Good to know + +**The fork, not the original.** The agent you interact with via `resolved.entry` is the +newly built fork, not the `coordinator` you declared. This matters if you pass the same +agent into multiple orchestrations — each one gets its own independent copy. + +**Each sub-agent is independent.** It gets its own model, system prompt, tools, and hooks. +The coordinator (fork) only sees the `description:` string — it can't peek inside. + +**The coordinator should not write content itself.** Its job is to call the right sub-agent +at the right time and pass through the result. The system prompt enforces this. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/07_delegate/main.py +``` + +## Try these prompts + +- `Write a short guide on how Python manages memory.` +- `Create a report on the benefits of test-driven development.` +- `Produce a concise overview of how HTTP/2 improves on HTTP/1.1.` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/07_delegate/config.yaml b/examples/07_delegate/config.yaml new file mode 100644 index 0000000..1b62831 --- /dev/null +++ b/examples/07_delegate/config.yaml @@ -0,0 +1,57 @@ +# 07_delegate — Multi-Agent Delegation +# +# Delegate orchestration: a new agent is forked from coordinator's blueprint +# with researcher and writer attached as @tool functions. +# The original coordinator agent is never mutated. +# +# Run: +# uv run python examples/07_delegate/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +agents: + researcher: + model: default + description: "Research a topic and return structured facts with sources." + system_prompt: | + You are a research specialist. Given a topic, produce a structured briefing: + - 2-3 key facts with clear explanations + - Any important caveats or nuances + - A one-paragraph summary at the end + Be factual, concise, and well-organised. + + writer: + model: default + description: "Write a polished, well-structured report from provided facts." + system_prompt: | + You are a technical writer. Given research material, produce a clear report: + - An informative title + - An executive summary (2-3 sentences) + - Well-structured body sections with headings + - A brief conclusion + Write in a professional but accessible tone. + + coordinator: + model: default + system_prompt: | + You coordinate a research and writing pipeline. + For every task: + 1. Call researcher to gather facts on the topic. + 2. Pass the facts to writer to produce the final report. + 3. Return the writer's output as your response. + Do not write content yourself — delegate all work. + +orchestrations: + main: + mode: delegate + entry_name: coordinator + connections: + - agent: researcher + description: "Research a topic and return structured facts with sources." + - agent: writer + description: "Write a polished report from the provided research material." + +entry: main diff --git a/examples/07_delegate/main.py b/examples/07_delegate/main.py new file mode 100644 index 0000000..ee9efb4 --- /dev/null +++ b/examples/07_delegate/main.py @@ -0,0 +1,48 @@ +"""07_delegate — Multi-Agent Delegation. + +A coordinator agent calls researcher and writer as tools. +strands-compose wires the connections from config.yaml automatically. + +Usage: + uv run python examples/07_delegate/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = "Write a short guide on how Python manages memory." + + +def main() -> None: + from strands_compose import load + + resolved = load(CONFIG) + agent = resolved.entry + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print("coordinator -> researcher + writer") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nAgent is thinking...") + result = agent(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/08_swarm/README.md b/examples/08_swarm/README.md new file mode 100644 index 0000000..5a82de1 --- /dev/null +++ b/examples/08_swarm/README.md @@ -0,0 +1,75 @@ +# 08 — Swarm (Autonomous Handoffs) + +> Peer agents that hand work off to each other — no central coordinator. + +## What this shows + +- `mode: swarm` in `orchestrations:` — agents decide on their own when to pass the baton +- `agents:` list — all participating peers +- `entry_name:` — which agent receives the initial task +- `max_handoffs:` — safety cap on total agent-to-agent transfers +- `handoff_to_agent` — a tool strands injects into every swarm agent automatically + +## How it works + +Unlike delegation (one boss calls sub-agents), a swarm has no fixed director. +Each agent decides autonomously when to hand off to a peer: + +``` +drafter ──handoff──▶ reviewer ──handoff──▶ drafter (if revisions needed) + └──handoff──▶ tech_lead (when approved) +``` + +strands injects a `handoff_to_agent(agent_name, context)` tool into every swarm agent. +When an agent calls it, execution transfers to the named peer with full context. + +```yaml +orchestrations: + review_team: + mode: swarm + agents: [drafter, reviewer, tech_lead] + entry_name: drafter + max_handoffs: 10 +``` + +## Good to know + +**Swarm vs Delegate.** Use swarm when agents need to collaborate back-and-forth (e.g. +drafting + reviewing cycles). Use delegate when there's a clear hierarchy and fixed +flow. + +**`max_handoffs` prevents infinite loops.** If agents keep handing off without +converging, the swarm stops after `max_handoffs` transfers. + +**Swarm agents can't have a session manager.** If you have a global `session_manager:`, +any agent used in a swarm must opt out with `session_manager: ~`. This is a strands +limitation — may be lifted in the future. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/08_swarm/main.py +``` + +## Try these prompts + +- `Write and review a Python function that validates an email address.` +- `Create and review a function that merges two sorted lists.` +- `Write a utility that parses a simple key=value config file.` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/08_swarm/config.yaml b/examples/08_swarm/config.yaml new file mode 100644 index 0000000..09c81d9 --- /dev/null +++ b/examples/08_swarm/config.yaml @@ -0,0 +1,52 @@ +# 08_swarm — Agent Swarm +# +# Swarm orchestration: peer agents with autonomous handoffs. +# strands injects handoff_to_agent() into every agent automatically. +# No coordinator — agents decide on their own when to pass the baton. +# +# Run: +# uv run python examples/08_swarm/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +agents: + drafter: + model: default + description: "Writes initial code drafts from a given specification." + system_prompt: | + You are a software developer. Write clean, working Python code. + When you have produced a draft, hand off to reviewer for a code review. + Address any issues raised by reviewer and hand back to reviewer again. + When reviewer approves, hand off to tech_lead for final sign-off. + + reviewer: + model: default + description: "Reviews code drafts for correctness, style, and edge cases." + system_prompt: | + You are a senior code reviewer. Review the submitted code for: + - Correctness and logic errors + - Edge cases not handled + - PEP 8 style adherence + - Missing docstrings or type hints + If you find issues, hand back to drafter with specific feedback. + If the code looks good, hand off to tech_lead for final approval. + + tech_lead: + model: default + description: "Makes final approval decisions on reviewed code." + system_prompt: | + You are the tech lead. Review the code and the review discussion. + Make a final decision: approve or request one last round of changes. + Provide a brief verdict and summarise the final version of the code. + +orchestrations: + review_team: + mode: swarm + agents: [drafter, reviewer, tech_lead] + entry_name: drafter + max_handoffs: 10 + +entry: review_team diff --git a/examples/08_swarm/main.py b/examples/08_swarm/main.py new file mode 100644 index 0000000..085a1d5 --- /dev/null +++ b/examples/08_swarm/main.py @@ -0,0 +1,48 @@ +"""08_swarm — Autonomous Handoffs. + +Peer agents hand work off to each other via handoff_to_agent. +strands-compose builds the Swarm from config.yaml automatically. + +Usage: + uv run python examples/08_swarm/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = "Write and review a Python function that validates an email address." + + +def main() -> None: + from strands_compose import load + + resolved = load(CONFIG) + swarm = resolved.entry + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print("drafter -> reviewer -> tech_lead (autonomous handoffs)") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nSwarm is working...") + result = swarm(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/09_graph/README.md b/examples/09_graph/README.md new file mode 100644 index 0000000..e1fde64 --- /dev/null +++ b/examples/09_graph/README.md @@ -0,0 +1,76 @@ +# 09 — Graph Pipeline + +> A fixed pipeline — nodes execute in the order you define, no surprises. + +## What this shows + +- `mode: graph` in `orchestrations:` — a deterministic DAG with explicit edges +- `edges:` list — each edge is a `from: -> to:` pair that locks in execution order +- `entry_name:` — the starting node (must have no incoming edges) +- The difference between graph and swarm: you decide the order, not the agents + +## How it works + +A graph pipeline executes nodes in topological order. The output of each node is +passed as context to the next. + +``` +content_strategist ──▶ content_writer ──▶ copy_editor +``` + +In YAML, edges are explicit `from: / to:` pairs: + +```yaml +orchestrations: + blog_pipeline: + mode: graph + entry_name: content_strategist + edges: + - from: content_strategist + to: content_writer + - from: content_writer + to: copy_editor +``` + +That's it — three agents, two edges, one pipeline. strands-compose builds the DAG +and runs each node in sequence. + +## Good to know + +**Graph vs Swarm vs Delegate.** Use graph when the execution order is fixed and +known upfront — e.g. content pipelines, multi-stage validation, ETL. Use swarm for +collaborative back-and-forth. Use delegate for a coordinator that picks tools on the fly. + +**Nodes at the same depth can run in parallel.** If two nodes share no dependency, +strands executes them concurrently — but that's handled for you. + +**Conditional edges** are supported too — see example 13 for that. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/09_graph/main.py +``` + +## Try these prompts + +- `Write a blog post about why Rust is gaining popularity among Python developers.` +- `Create a post about the importance of code review in software teams.` +- `Write about how large language models are changing software development.` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/09_graph/config.yaml b/examples/09_graph/config.yaml new file mode 100644 index 0000000..bf017f7 --- /dev/null +++ b/examples/09_graph/config.yaml @@ -0,0 +1,55 @@ +# 09_graph — Graph Pipeline +# +# Graph orchestration: deterministic DAG with explicit edges. +# Execution order is fixed by the edges — no agent-driven handoffs. +# Nodes at the same depth can execute in parallel (strands handles this). +# +# Run: +# uv run python examples/09_graph/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +agents: + content_strategist: + model: default + system_prompt: | + You are a content strategist. Given a blog post topic, produce: + - A clear title + - A target audience description (one sentence) + - A structured outline with 4-5 section headings and a bullet point per section + Output only the title, audience, and outline — nothing else. + + content_writer: + model: default + system_prompt: | + You are a content writer. You receive a topic and outline. + Write a complete blog post following the outline exactly. + Use clear, engaging prose. Include an introduction and conclusion. + Output the full post in markdown format. + + copy_editor: + model: default + system_prompt: | + You are a copy editor. You receive a draft blog post. + Review and improve it for: + - Grammar and spelling + - Sentence variety and flow + - Consistent tone (professional but approachable) + - Header formatting in markdown + Return the polished final version of the post. + +orchestrations: + blog_pipeline: + mode: graph + # entry_name must be a node with no incoming edges (the pipeline start). + entry_name: content_strategist + edges: + - from: content_strategist + to: content_writer + - from: content_writer + to: copy_editor + +entry: blog_pipeline diff --git a/examples/09_graph/main.py b/examples/09_graph/main.py new file mode 100644 index 0000000..8d5b009 --- /dev/null +++ b/examples/09_graph/main.py @@ -0,0 +1,48 @@ +"""09_graph — Graph Pipeline. + +A deterministic DAG: content_strategist -> content_writer -> copy_editor. +strands-compose builds the graph from edges defined in config.yaml. + +Usage: + uv run python examples/09_graph/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = "Write a blog post about why Rust is gaining popularity among Python developers." + + +def main() -> None: + from strands_compose import load + + resolved = load(CONFIG) + graph = resolved.entry + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print("content_strategist -> content_writer -> copy_editor (fixed order)") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nPipeline is running...") + result = graph(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/10_nested/README.md b/examples/10_nested/README.md new file mode 100644 index 0000000..0fee740 --- /dev/null +++ b/examples/10_nested/README.md @@ -0,0 +1,85 @@ +# 10 — Nested Orchestration + +> A swarm inside a delegate — compose orchestrations like building blocks. + +## What this shows + +- **Referencing an orchestration inside another orchestration** — `content_team` (swarm) + is used as a child node in `pipeline` (delegate) +- **Topological sort** — strands-compose builds `content_team` before `pipeline` automatically +- How agents, swarms, and graphs all become first-class nodes in a larger system + +## How it works + +``` +pipeline (delegate) + ├── content_team (swarm) — researcher ↔ writer ↔ reviewer + └── qa_bot (agent) — quality-checks the final output +``` + +The coordinator never writes content itself — it delegates content production to the +swarm and quality assurance to `qa_bot`. From the coordinator's perspective, +`content_team` is just another tool. + +```yaml +orchestrations: + content_team: + mode: swarm + agents: [researcher, writer, reviewer] + entry_name: researcher + max_handoffs: 15 + + pipeline: + mode: delegate + connections: + coordinator: + - agent: content_team # ← references the swarm above + description: "Content production team." + - agent: qa_bot + description: "Quality assurance check." +``` + +strands-compose topologically sorts the orchestrations: `content_team` is built first, +then `pipeline` wraps it as a delegate tool. + +## Good to know + +**Agents and orchestrations share a single namespace.** You can't have an agent and an +orchestration with the same name — strands-compose raises an error. + +**Why YAML wins here.** The programmatic version would need ~60 lines to recreate what +YAML expresses in 20. More importantly, the orchestration *structure* is immediately +readable — you see the full system topology at a glance. + +**You can nest arbitrarily.** A graph can include a delegate which includes a swarm. +As long as there are no cycles in the orchestration dependency graph, strands-compose +sorts and builds everything. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/10_nested/main.py +``` + +## Try these prompts + +- `Create a comprehensive guide to setting up CI/CD with GitHub Actions.` +- `Produce a well-researched article about the future of edge computing.` +- `Write a technical deep-dive on how database indexes work.` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/10_nested/config.yaml b/examples/10_nested/config.yaml new file mode 100644 index 0000000..1837550 --- /dev/null +++ b/examples/10_nested/config.yaml @@ -0,0 +1,105 @@ +# 10_nested — Nested Orchestration +# +# A Swarm embedded as a named target inside a Delegate orchestration. +# strands-compose topological sort builds content_team before pipeline. +# +# Namespace rule: agents and orchestrations share a single namespace — +# 'content_team' here cannot be both an agent name and an orchestration name. +# +# Run: +# uv run python examples/10_nested/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +x-hooks: &hooks + - type: strands_compose.hooks:MaxToolCallsGuard + params: { max_calls: 20 } + - type: strands_compose.hooks:ToolNameSanitizer + +agents: + # ── Content production swarm agents ────────────────────────────────────── + researcher: + model: default + description: "Research the topic and gather structured facts." + hooks: *hooks + system_prompt: | + You are a research analyst. Given a topic: + 1. Identify the 2-3 most important aspects to cover. + 2. Provide factual, structured information for each aspect. + 3. Note any key statistics, dates, or authoritative sources. + Hand off to reviewer when your research briefing is complete. + + reviewer: + model: default + description: "Review and improve content for quality and accuracy." + hooks: *hooks + system_prompt: | + You are a senior editor. Review the submitted draft for: + - Technical accuracy and completeness + - Logical flow and structure + - Clarity and readability + If it is publication-ready, output the final improved version and stop. + + # ── Quality assurance agent ─────────────────────────────────────────────── + qa_bot: + model: default + description: "Run a final quality check on the produced content." + hooks: *hooks + system_prompt: | + You are a QA specialist. Review the provided content and check for: + - Factual claims that seem unsupported or exaggerated + - Missing sections promised in the introduction + - Formatting issues (broken markdown, inconsistent headings) + - Overall readability score (Good / Needs Work / Poor) + You must be provided with specific content/document to review. + If you don't receive any content, respond with "No content received for QA." + Do not make assumptions or generate content on your own. + Return a short QA report followed by the approved content. + + # ── Coordinator ─────────────────────────────────────────────────────────── + coordinator: + model: default + hooks: *hooks + conversation_manager: + type: strands.agent:SlidingWindowConversationManager + params: + window_size: 40 + should_truncate_results: false + system_prompt: | + You orchestrate the full content production pipeline and make final decisions. + For every request: + 1. Delegate to content_team to research and prepare the content. + 2. Pass their output to qa_bot for quality assurance validation. + MANDATORY! Include exactly what you received from content_team. + 3. EVALUATE qa_bot's assessment: + - If QA approves: Return the final approved content to the user immediately. + - If QA identifies issues: Decide whether to: + a) Send the content back to content_team with specific QA feedback for revision, OR + b) Reject and ask the user to clarify the request. + You do not produce content yourself. Your role is to orchestrate, + validate, and make final approval decisions. + +# ── Orchestrations ──────────────────────────────────────────────────────────── +orchestrations: + # Inner: autonomous peer swarm for content production + content_team: + mode: swarm + agents: [researcher, reviewer] + entry_name: researcher + max_handoffs: 10 + + # Outer: coordinator delegates to the swarm + QA agent + manager: + mode: delegate + entry_name: coordinator + connections: + # content_team references the orchestration above — not a plain agent. + - agent: content_team + description: "Content production team: researches and prepares the content." + - agent: qa_bot + description: "Quality assurance: checks the final content for accuracy and completeness." + +entry: manager diff --git a/examples/10_nested/main.py b/examples/10_nested/main.py new file mode 100644 index 0000000..80bdc02 --- /dev/null +++ b/examples/10_nested/main.py @@ -0,0 +1,49 @@ +"""10_nested — Nested Orchestration. + +A Swarm (content_team) embedded inside a Delegate (pipeline). +strands-compose topological sort builds the inner swarm first, then wires +it as a delegate tool for the coordinator — all from config.yaml. + +Usage: + uv run python examples/10_nested/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = "Write a short, 5-sentence article about Python." + + +def main() -> None: + from strands_compose import load + + resolved = load(CONFIG) + agent = resolved.entry + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print("coordinator -> [content_team(researcher <-> reviewer), qa_bot]") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nAgent is thinking...") + result = agent(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/11_multi_file_config/README.md b/examples/11_multi_file_config/README.md new file mode 100644 index 0000000..75a9f7a --- /dev/null +++ b/examples/11_multi_file_config/README.md @@ -0,0 +1,88 @@ +# 11 — Multi-File Config + +> Split one config across multiple YAML files — each file owns one concern. + +## What this shows + +- `load(["base.yaml", "agents.yaml"])` — pass a list and strands-compose merges them +- Neither file is complete on its own; together they form a runnable config +- Agents in `agents.yaml` reference models defined in `base.yaml` — cross-file references just work +- `vars:` are scoped per file — each source resolves its own variables independently + +## How it works + +``` +load(["base.yaml", "agents.yaml"]) + ↓ ↓ + vars + models agents + entry + └─────────┬─────────┘ + merged config +``` + +**Merging rules:** +- **Collections** (`agents`, `models`, `mcp_servers`, etc.) are **combined** — each file + contributes unique names +- **Singletons** (`entry`, `log_level`) use **last-wins** — the last file to define it wins +- **Duplicate names** across files raise `ValueError` — intentional, not a bug + +| File | Contents | Standalone? | +|------|----------|-------------| +| `base.yaml` | `vars`, `models` | ✗ no agents, no entry | +| `agents.yaml` | `agents`, `entry` | ✗ no models defined | +| both together | complete config | ✓ runnable | + +## Good to know + +**Infra / app separation.** One team owns `base.yaml` (models, MCP servers), another +owns `agents.yaml` (agent definitions). Swap `base.yaml` per environment without +touching agent logic. + +**Variables are file-scoped.** `${MODEL}` in `base.yaml` resolves from `base.yaml`'s +`vars:` block (or env). It won't see variables defined in `agents.yaml`. + +**YAML anchors are file-scoped too.** An anchor in `base.yaml` can't be referenced +from `agents.yaml`. For shared blocks across files, use variables instead. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +### Linux / macOS + +```bash +uv run python examples/11_multi_file_config/main.py + +# Override the model +MODEL=us.anthropic.claude-sonnet-4-6-v1:0 uv run python examples/11_multi_file_config/main.py +``` + +### Windows + +```cmd +uv run python examples\11_multi_file_config\main.py + +REM Override the model +set MODEL=us.anthropic.claude-sonnet-4-6-v1:0 +uv run python examples\11_multi_file_config\main.py +``` + +## Try these prompts + +- `What is the difference between concurrency and parallelism?` +- `Explain Python's GIL in one paragraph.` +- `What are the SOLID principles?` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/11_multi_file_config/agents.yaml b/examples/11_multi_file_config/agents.yaml new file mode 100644 index 0000000..bfeafdb --- /dev/null +++ b/examples/11_multi_file_config/agents.yaml @@ -0,0 +1,18 @@ +# 11_multi_file_config — agents configuration +# +# Agent definitions and entry point. +# This file is intentionally incomplete — it has no models defined. +# Pair it with base.yaml to get a runnable config: +# +# load(["base.yaml", "agents.yaml"]) +# +# The 'default' model referenced here is defined in base.yaml. + +agents: + assistant: + model: default # defined in base.yaml + system_prompt: | + You are a helpful and friendly assistant. + Keep answers clear and concise. + +entry: assistant diff --git a/examples/11_multi_file_config/base.yaml b/examples/11_multi_file_config/base.yaml new file mode 100644 index 0000000..b24b09e --- /dev/null +++ b/examples/11_multi_file_config/base.yaml @@ -0,0 +1,19 @@ +# 11_multi_file_config — base configuration +# +# Infrastructure only: variables and model definitions. +# This file is intentionally incomplete — it has no agents and no entry point. +# Pair it with agents.yaml to get a runnable config: +# +# load(["base.yaml", "agents.yaml"]) +# +# Values can be overridden by environment variables at startup: +# MODEL=us.anthropic.claude-sonnet-4-6-v1:0 uv run python examples/11_multi_file_config/main.py + +vars: + # Read MODEL from env; fall back to a default when not set. + MODEL: ${MODEL:-openai.gpt-oss-20b-1:0} + +models: + default: + provider: bedrock + model_id: ${MODEL} diff --git a/examples/11_multi_file_config/main.py b/examples/11_multi_file_config/main.py new file mode 100644 index 0000000..ed97aaf --- /dev/null +++ b/examples/11_multi_file_config/main.py @@ -0,0 +1,51 @@ +"""11_multi_file_config — Multi-File Config. + +Split a single logical config across multiple YAML files. +Neither file is valid alone — together they form a complete config. + + base.yaml — vars, models (infrastructure) + agents.yaml — agents, entry (application) + +Usage: + uv run python examples/11_multi_file_config/main.py + MODEL=us.anthropic.claude-sonnet-4-6-v1:0 uv run python examples/11_multi_file_config/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +BASE = Path(__file__).parent / "base.yaml" +AGENTS = Path(__file__).parent / "agents.yaml" + + +def main() -> None: + from strands_compose import load + + resolved = load([BASE, AGENTS]) + agent = resolved.entry + print(f"\n{52 * '-'}") + print("Loaded: base.yaml + agents.yaml (merged)") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nAgent is thinking...") + result = agent(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/12_streaming/README.md b/examples/12_streaming/README.md new file mode 100644 index 0000000..18aa831 --- /dev/null +++ b/examples/12_streaming/README.md @@ -0,0 +1,84 @@ +# 12 — Streaming + +> Watch every token, tool call, and agent event in real time. + +## What this shows + +- `wire_event_queue()` — wire all agents to a single async queue that emits `StreamEvent`s +- `AnsiRenderer` — built-in terminal renderer that prints events with colours as they arrive +- How strands-compose turns agent lifecycle events into a consumable stream — the same + mechanism that powers SSE endpoints, WebSocket feeds, and audit logs + +## How it works + +```python +from strands_compose import load, AnsiRenderer + +resolved = load("config.yaml") +queue = resolved.wire_event_queue() +``` + +`resolved.wire_event_queue()` installs an `EventPublisher` hook on every agent. As the agent runs, +the hook converts lifecycle events (tokens, tool calls, completions) into `StreamEvent` +objects and pushes them to the queue. Your consumer loop is simple: + +```python +renderer = AnsiRenderer() +while (event := await queue.get()) is not None: + renderer.render(event) +renderer.flush() +``` + +### Event types + +| Type | When it fires | +|------|---------------| +| `agent_start` | Agent begins processing | +| `token` | Streaming text chunk | +| `reasoning` | Streaming reasoning chunk | +| `tool_start` | Tool call begins | +| `tool_end` | Tool call finished | +| `complete` | Agent finished (includes token usage) | +| `node_start` / `node_stop` | Swarm / Graph enters/leaves a node | +| `handoff` | Swarm transfers control | + +## Good to know + +**This example is async.** Streaming requires `asyncio` — the agent runs via +`invoke_async` so both the agent and the queue consumer share the same event loop. + +**`AnsiRenderer` is optional.** It's a convenience for terminals. In production you'd +consume the queue and convert events to SSE chunks (see `OpenAIStreamConverter`) or +NDJSON (`RawStreamConverter`). + +**`queue.flush()`** resets the queue between turns so events from one invocation +don't leak into the next. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/12_streaming/main.py +``` + +## Try these prompts + +- `Analyse the impact of large language models on software engineering.` +- `Research current trends in renewable energy adoption.` +- `Examine how remote work has changed urban real-estate markets.` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/12_streaming/config.yaml b/examples/12_streaming/config.yaml new file mode 100644 index 0000000..c4819e0 --- /dev/null +++ b/examples/12_streaming/config.yaml @@ -0,0 +1,59 @@ +# 12_streaming — Streaming Agent Output +# +# A delegate orchestration with three agents: researcher, analyst, coordinator. +# The example shows how to stream all agent lifecycle events in real time +# using make_event_queue() from strands_compose. +# +# Run: +# uv run python examples/12_streaming/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +agents: + researcher: + model: default + hooks: + - type: strands_compose.hooks:ToolNameSanitizer + system_prompt: | + You are a research analyst. Given a topic: + 1. Identify the 5-7 most relevant facts. + 2. Present them as concise, numbered bullet points. + Return only your factual briefing — nothing else. + + analyst: + model: default + hooks: + - type: strands_compose.hooks:ToolNameSanitizer + system_prompt: | + You are an insights analyst. Given a research briefing: + 1. Surface 2-3 actionable insights or emerging trends. + 2. Explain the "so what" for a practitioner in plain language. + Return only your analysis — nothing else. + + coordinator: + model: default + hooks: + - type: strands_compose.hooks:ToolNameSanitizer + system_prompt: | + You orchestrate a two-step research -> analysis. + For every request: + 1. Call researcher to gather the relevant facts. + 2. Pass those facts to analyst for interpretation. + 3. Study the analyst's output and prepare final response. + Delegate all work you can, your job is to coordinate and synthesize, + not to research or analyze yourself. + +orchestrations: + main: + mode: delegate + entry_name: coordinator + connections: + - agent: researcher + description: "Research the topic and return a factual briefing." + - agent: analyst + description: "Analyse the briefing and return key practitioner insights." + +entry: main diff --git a/examples/12_streaming/main.py b/examples/12_streaming/main.py new file mode 100644 index 0000000..319ecdd --- /dev/null +++ b/examples/12_streaming/main.py @@ -0,0 +1,74 @@ +"""12_streaming — Streaming Agent Output. + +Watch every token, tool call, and lifecycle event in real time. +wire_event_queue() wires all agents and returns an async queue. +AnsiRenderer prints each event with colours as it arrives. + +Usage: + uv run python examples/12_streaming/main.py +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +from strands_compose import AnsiRenderer, cli_errors, load + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = "Analyse the impact of large language models on software engineering." + + +async def _stream(prompt: str, entry, queue): + """Invoke the entry agent and render the event stream.""" + queue.flush() + result = None + + async def _invoke() -> None: + nonlocal result + try: + result = await entry.invoke_async(prompt) + except Exception: + pass # nosec B110 + finally: + await queue.close() + + asyncio.create_task(_invoke()) + + renderer = AnsiRenderer() + while (event := await queue.get()) is not None: + renderer.render(event) + renderer.flush() + return result + + +async def _main() -> None: + resolved = load(CONFIG) + entry = resolved.entry + queue = resolved.wire_event_queue() + + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print("researcher -> analyst -> coordinator (with live streaming)") + print("Type a message and press Enter. Empty line to exit.\n") + + try: + while True: + msg = await asyncio.to_thread(lambda: input("You: ").strip()) + if not msg: + break + print("\nAgent is thinking...") + result = await _stream(msg, entry, queue) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + with cli_errors(): + asyncio.run(_main()) diff --git a/examples/13_graph_conditions/README.md b/examples/13_graph_conditions/README.md new file mode 100644 index 0000000..8bb9ccb --- /dev/null +++ b/examples/13_graph_conditions/README.md @@ -0,0 +1,90 @@ +# 13 — Graph with Conditional Edges + +> A graph that branches — edges fire only when a condition function returns True. + +## What this shows + +- `condition:` on graph edges — a Python callable that decides whether the edge fires +- How to build branching pipelines where one node routes to different next steps +- `reset_on_revisit:` — allows a node to run more than once (for retry loops) + +## How it works + +A regular graph edge always fires. A **conditional edge** fires only when its +`condition:` callable returns `True`. The callable receives a context dict and +returns a boolean: + +```python +# conditions.py +def needs_revision(context: dict) -> bool: + """Route back to writer if the review says 'REVISE'.""" + last_output = str(context.get("last_output", "")) + return "REVISE" in last_output.upper() + +def is_approved(context: dict) -> bool: + """Route to publisher if the review says 'APPROVED'.""" + last_output = str(context.get("last_output", "")) + return "APPROVED" in last_output.upper() +``` + +Wire them in YAML: + +```yaml +orchestrations: + pipeline: + mode: graph + entry_name: writer + reset_on_revisit: true + edges: + - from: writer + to: reviewer + - from: reviewer + to: writer # retry loop + condition: ./conditions.py:needs_revision + - from: reviewer + to: publisher # happy path + condition: ./conditions.py:is_approved +``` + +This creates a loop: writer -> reviewer -> (back to writer if revisions needed, forward +to publisher if approved). + +## Good to know + +**`reset_on_revisit: true`** is required for loops. Without it, a node that already ran +won't execute again — the edge is silently skipped. + +**Conditions are Python callables**, loaded via import path (`module:func` or +`./file.py:func`). They receive a dict with the graph's execution context. + +**If no conditional edge fires**, the graph stops at that node. Make sure your +conditions are exhaustive — or add a fallback unconditional edge. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/13_graph_conditions/main.py +``` + +## Try these prompts + +- `Write a short Python tutorial about list comprehensions.` +- `Write a paragraph explaining what Docker containers are.` +- `Create a quick guide to Python's asyncio library.` + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/13_graph_conditions/conditions.py b/examples/13_graph_conditions/conditions.py new file mode 100644 index 0000000..453f9a9 --- /dev/null +++ b/examples/13_graph_conditions/conditions.py @@ -0,0 +1,33 @@ +"""Edge condition functions for the 13_graph_conditions example. + +Each function receives the graph's execution context dict and returns +True/False to decide whether the edge should fire. +""" + +from __future__ import annotations + + +def needs_revision(context: dict) -> bool: + """Route back to writer if the reviewer says REVISE. + + Args: + context: Graph execution context with last_output from the reviewer. + + Returns: + True if the content needs revision. + """ + last_output = str(context.get("last_output", "")) + return "REVISE" in last_output.upper() + + +def is_approved(context: dict) -> bool: + """Route to publisher if the reviewer says APPROVED. + + Args: + context: Graph execution context with last_output from the reviewer. + + Returns: + True if the content is approved for publishing. + """ + last_output = str(context.get("last_output", "")) + return "APPROVED" in last_output.upper() diff --git a/examples/13_graph_conditions/config.yaml b/examples/13_graph_conditions/config.yaml new file mode 100644 index 0000000..b70198c --- /dev/null +++ b/examples/13_graph_conditions/config.yaml @@ -0,0 +1,55 @@ +# 13_graph_conditions — Graph with Conditional Edges +# +# A write -> review -> publish pipeline with a conditional retry loop. +# The reviewer can send content back to the writer or forward it to the publisher. +# +# Run: +# uv run python examples/13_graph_conditions/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +agents: + writer: + model: default + system_prompt: | + You are a technical writer. Write clear, well-structured content on the given topic. + If you receive revision feedback, incorporate it and rewrite the content. + Output only the content — no meta-commentary. + + reviewer: + model: default + system_prompt: | + You are an editor. Review the submitted content for clarity, accuracy, and completeness. + If it needs work, start your response with "REVISE:" followed by specific feedback. + If it is ready, start your response with "APPROVED:" followed by the final content. + You must always start with either REVISE: or APPROVED: — no exceptions. + + publisher: + model: default + system_prompt: | + You are a publisher. You receive approved content. + Format it nicely with a title and clean markdown. Add a brief editor's note at the top. + Output only the final published version. + +orchestrations: + pipeline: + mode: graph + entry_name: writer + reset_on_revisit: true + max_node_executions: 6 + edges: + # writer always sends to reviewer + - from: writer + to: reviewer + # reviewer routes conditionally + - from: reviewer + to: writer + condition: ./conditions.py:needs_revision + - from: reviewer + to: publisher + condition: ./conditions.py:is_approved + +entry: pipeline diff --git a/examples/13_graph_conditions/main.py b/examples/13_graph_conditions/main.py new file mode 100644 index 0000000..19a78e2 --- /dev/null +++ b/examples/13_graph_conditions/main.py @@ -0,0 +1,49 @@ +"""13_graph_conditions — Graph with Conditional Edges. + +A write -> review -> publish pipeline where the reviewer can send content +back for revision or approve it for publication. Conditional edges +let an agent's output decide the next step. + +Usage: + uv run python examples/13_graph_conditions/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = "Write a short blog post about why YAML is great for configuration." + + +def main() -> None: + from strands_compose import load + + resolved = load(CONFIG) + graph = resolved.entry + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print("writer -> reviewer -> (writer again | publisher)") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nPipeline is running...") + result = graph(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/14_agent_factory/README.md b/examples/14_agent_factory/README.md new file mode 100644 index 0000000..3759870 --- /dev/null +++ b/examples/14_agent_factory/README.md @@ -0,0 +1,75 @@ +# 14 — Custom Agent Factory + +> Replace the default `strands.Agent()` constructor with your own factory function — all from YAML. + +## What this shows + +- **`type:`** — point an agent at a custom callable instead of the built-in constructor +- **`agent_kwargs:`** — pass additional keyword arguments that only your factory understands +- Factory receives all standard params (`name`, `agent_id`, `model`, `system_prompt`, `tools`, `hooks`, `session_manager`) plus your extras + +## How it works + +```yaml +agents: + assistant: + type: ./factory.py:create_agent # your factory, not Agent() + model: default + system_prompt: You are a helpful assistant. + agent_kwargs: + greeting: "Ahoy, captain!" + personality: pirate +``` + +strands-compose imports `create_agent` from `factory.py` and calls it with +all the standard agent parameters **plus** everything in `agent_kwargs`. +Your factory must return a `strands.Agent` instance. + +## Good to know + +> **⚠️ `agent_kwargs` is an expert feature.** +> +> `strands.Agent.__init__()` does **not** accept `**kwargs` — it has a fixed set +> of explicit parameters. strands-compose does **not** validate `agent_kwargs` +> at the schema level. If invalid keys reach `Agent()`, you get a `TypeError` +> at runtime. +> +> **Your factory is responsible for:** +> 1. Consuming any custom keys (like `greeting`, `personality`) before forwarding. +> 2. Filtering the remaining kwargs to only Agent-accepted parameters. +> +> See `factory.py` in this example for the recommended pattern. + +- You can also use `type:` with a module path: `my_package.factories:create_agent`. +- If a factory returns something other than `strands.Agent`, strands-compose raises a `TypeError` immediately. + +## Prerequisites + +```bash +pip install strands-agents strands-agents-builder strands-compose +``` + +## Run + +```bash +uv run python examples/14_agent_factory/main.py +``` + +## Try these prompts + +| Prompt | What to expect | +|--------|----------------| +| `What is the meaning of life?` | Response starts with "Ahoy, captain!" in pirate style | +| `Tell me about Python.` | Pirate personality persists across messages | + +## Advanced topic — suppress default callback logging + +Strands agents log actions to the console through their default `callback_handler`. +If you want cleaner example output, set the handler to `null` in `agent_kwargs` for any agent: + +```yaml +agents: + my_agent: + agent_kwargs: + callback_handler: null # or ~ +``` diff --git a/examples/14_agent_factory/config.yaml b/examples/14_agent_factory/config.yaml new file mode 100644 index 0000000..0180768 --- /dev/null +++ b/examples/14_agent_factory/config.yaml @@ -0,0 +1,23 @@ +# 14_agent_factory — Custom Agent Factory +# +# Use `type:` to plug in your own agent factory instead of the default +# strands.Agent constructor. Pass extra parameters via `agent_kwargs:`. +# +# Run: +# uv run python examples/14_agent_factory/main.py + +models: + default: + provider: bedrock + model_id: openai.gpt-oss-20b-1:0 + +agents: + assistant: + type: ./factory.py:create_agent # custom factory replaces Agent() + model: default + system_prompt: You are a helpful assistant. + agent_kwargs: # extra kwargs forwarded to factory + greeting: "Ahoy, captain!" + personality: pirate + +entry: assistant diff --git a/examples/14_agent_factory/factory.py b/examples/14_agent_factory/factory.py new file mode 100644 index 0000000..5bc8cd6 --- /dev/null +++ b/examples/14_agent_factory/factory.py @@ -0,0 +1,53 @@ +"""Custom agent factory for strands-compose. + +A factory is any callable that receives the standard agent parameters +plus whatever you put in ``agent_kwargs`` and returns a ``strands.Agent``. + +strands-compose calls it like:: + + factory( + name=..., + agent_id=..., + model=..., + system_prompt=..., + description=..., + tools=..., + hooks=..., + session_manager=..., + **agent_kwargs, + ) + +⚠️ ``strands.Agent.__init__`` does NOT accept **kwargs — it has +explicit parameters only. Your factory MUST consume any custom keys +from ``agent_kwargs`` before forwarding the rest to ``Agent()``. +""" + +from __future__ import annotations + +from strands import Agent + + +def create_agent( + *, + name: str, + greeting: str = "Hello!", + personality: str = "friendly", + **kwargs, +) -> Agent: + """Create an Agent with a personality injected into the system prompt. + + ``greeting`` and ``personality`` come from ``agent_kwargs`` in YAML. + """ + # Extract and enhance system_prompt + system_prompt = kwargs.pop("system_prompt", "") or "" + enhanced_prompt = ( + f"{system_prompt}\n\n" + f"Your personality is: {personality}.\n" + f"Always start your first reply with: {greeting}" + ) + + return Agent( + name=name, + system_prompt=enhanced_prompt, + **kwargs, + ) diff --git a/examples/14_agent_factory/main.py b/examples/14_agent_factory/main.py new file mode 100644 index 0000000..332126c --- /dev/null +++ b/examples/14_agent_factory/main.py @@ -0,0 +1,48 @@ +"""14_agent_factory — Custom Agent Factory. + +Use ``type:`` on an agent to replace the default ``strands.Agent()`` +constructor with your own factory function. Extra parameters travel +through ``agent_kwargs``. + +Usage: + uv run python examples/14_agent_factory/main.py +""" + +from __future__ import annotations + +from pathlib import Path + +CONFIG = Path(__file__).parent / "config.yaml" +STARTER = "What is the meaning of life?" + + +def main() -> None: + from strands_compose import load + + resolved = load(CONFIG) + agent = resolved.entry + print(f"\n{52 * '-'}") + print(f"Try: {STARTER}\n") + print("Type a message and press Enter. Empty line to exit.\n") + try: + while True: + msg = input("You: ").strip() + if not msg: + break + print("\nAgent is thinking...") + result = agent(msg) + print(f"\n\n{52 * '-'}") + print(f"Agent: {result}\n") + except KeyboardInterrupt: + print("\nGoodbye!") + finally: + resolved.mcp_lifecycle.stop() + + +# ── entry point ─────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + from strands_compose import cli_errors + + with cli_errors(): + main() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..731e775 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,36 @@ +# strands-compose Examples + +Each example is a self-contained folder with a `README.md`, `config.yaml`, and `main.py`. + +| # | Folder | What it demonstrates | +|---|--------|----------------------| +| 01 | [01_minimal](./01_minimal/) | `load()` in one line — the simplest possible agent | +| 02 | [02_vars_and_anchors](./02_vars_and_anchors/) | Variables & YAML anchors — DRY configuration patterns | +| 03 | [03_tools](./03_tools/) | `tools:` list — auto-loading Python functions as agent tools | +| 04 | [04_session](./04_session/) | `session_manager:` — persistent memory across turns | +| 05 | [05_hooks](./05_hooks/) | `hooks:` — `MaxToolCallsGuard`, `ToolNameSanitizer`, `StopGuard` | +| 06 | [06_mcp](./06_mcp/) | MCP — all three connection modes: local server (`mcp_servers:`), external URL (`url:`), stdio (`command:`) | +| 07 | [07_delegate](./07_delegate/) | `mode: delegate` — coordinator routes to specialist agents | +| 08 | [08_swarm](./08_swarm/) | `mode: swarm` — peer agents hand off autonomously | +| 09 | [09_graph](./09_graph/) | `mode: graph` — explicit DAG pipeline between agents | +| 10 | [10_nested](./10_nested/) | Nested orchestration — Swarm inside a Delegate, `node_as_tool()` | +| 11 | [11_multi_file_config](./11_multi_file_config/) | Split config across files — infrastructure in one YAML, agents in another | +| 12 | [12_streaming](./12_streaming/) | `wire_event_queue()` — stream every token, tool call, and completion live | +| 13 | [13_graph_conditions](./13_graph_conditions/) | Conditional graph edges — `condition:`, `reset_on_revisit`, `max_node_executions` | +| 14 | [14_agent_factory](./14_agent_factory/) | `type:` + `agent_kwargs:` — custom agent factory instead of default `Agent()` | + +## Prerequisites + +```bash +uv sync +``` + +Set `AWS_REGION` and valid Bedrock credentials before running. + +The default model is `openai.gpt-oss-20b-1:0`; override per-directory with `MODEL=`. + +## Running an example + +```bash +uv run python examples/01_minimal/main.py +``` diff --git a/examples/TEMPLATE_EXAMPLE.md b/examples/TEMPLATE_EXAMPLE.md new file mode 100644 index 0000000..e328438 --- /dev/null +++ b/examples/TEMPLATE_EXAMPLE.md @@ -0,0 +1,62 @@ +> Example README Template +> +> Use this structure for every example README in this directory. +> Keep the tone casual and user-friendly — explain what the example does and +> how to run it, but don't dive into internals unless relevant. +> +> Copy the skeleton below and fill in the sections. + +--- + +# NN — Title + +> One-line summary: what does this example show? + +## What this shows + +A short description (2–5 sentences or a bulleted list) of the features or +concepts this example covers. Focus on *what the user learns*, not on +implementation details. + +## How it works + +Explain the outer interface: which strands-compose functions or config keys +are used and what they produce. Keep it high-level — show a snippet or a +small diagram if helpful, but don't go deep into source code. + +## Good to know + +Optional section. Add tips, gotchas, or recommendations that help the user +at this stage. Remove this section if there's nothing extra to mention. + +## Prerequisites + +- AWS credentials configured (`aws configure` or environment variables) +- Dependencies installed: `uv sync` + +## Run + +```bash +uv run python examples/NN_name/main.py +``` + +If there are environment variable overrides or platform differences, show them: + +### Linux / macOS + +```bash +VAR=value uv run python examples/NN_name/main.py +``` + +### Windows + +```cmd +set VAR=value +uv run python examples\NN_name\main.py +``` + +## Try these prompts + +- `Prompt suggestion 1` +- `Prompt suggestion 2` +- `Prompt suggestion 3` diff --git a/justfile b/justfile new file mode 100644 index 0000000..80da9cb --- /dev/null +++ b/justfile @@ -0,0 +1,29 @@ +# https://just.systems/man/en/ + +# SETTINGS + +set dotenv-load := true +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] + +# VARIABLES + +PACKAGE := "strands-compose" +SOURCES := "src/strands_compose" +TESTS := "tests" +EXAMPLES := "examples" + +# DEFAULTS + +# display help information +default: + @just --list + +# IMPORTS + +import 'tasks/check.just' +import 'tasks/clean.just' +import 'tasks/commit.just' +import 'tasks/format.just' +import 'tasks/install.just' +import 'tasks/release.just' +import 'tasks/test.just' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9d1ddb5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,105 @@ +[project] +name = "strands-compose" +version = "0.1.0" +description = "Zero-code YAML-driven agent orchestration over strands-agents" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "strands-agents~=1.32.0", + "pydantic>=2.12.5", + "pyyaml>=6.0.0", + "mcp>=1.24.0", +] + +[project.optional-dependencies] +agentcore-memory = [ + "bedrock-agentcore>=1.4.0", +] +ollama = [ + "strands-agents[ollama]~=1.32.0", +] +openai = [ + "strands-agents[openai]~=1.32.0", +] +gemini = [ + "strands-agents[gemini]~=1.32.0", +] + +[dependency-groups] +dev = [ + "ty>=0.0.17", + "bandit>=1.9.2", + "coverage>=7.12.0", + "pytest-asyncio>=1.2.0", + "pytest-cov>=7.0.0", + "pytest-mock>=3.15.1", + "pytest-xdist>=3.8.0", + "pytest>=9.0.2", + "ruff>=0.14.8", + "rust-just>=1.42.4", + "commitizen>=4.8.4", + "pre-commit>=4.3.0", + "starlette>=0.27.0", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src/strands_compose"] + +[tool.bandit] +targets = ["src/strands_compose"] + +[tool.bandit.assert_used] +skips = ['*_test.py', '*/test_*.py'] + +[tool.coverage.run] +branch = true +source = ["src/strands_compose"] +omit = ["__main__.py"] + +[tool.pytest.ini_options] +pythonpath = ["src"] +testpaths = ["tests"] +norecursedirs = ["__pycache__"] +python_files = ["test_*.py"] +python_functions = ["test_*"] +addopts = "-v --tb=short" +markers = [ + "integration: Integration tests (full pipeline)", + "ollama: Tests requiring local Ollama", + "bedrock: Tests requiring AWS Bedrock", +] +asyncio_mode = "auto" + +[tool.commitizen] +name = "cz_conventional_commits" +version_provider = "pep621" +version_files = [ + "pyproject.toml:^version", +] +tag_format = "v$version" +update_changelog_on_bump = true +changelog_file = "CHANGELOG.md" +changelog_incremental = true +major_version_zero = true + +[tool.ruff] +fix = true +indent-width = 4 +line-length = 100 + +[tool.ruff.format] +docstring-code-format = true + +[tool.ruff.lint.pydocstyle] +convention = "google" + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["D100", "D101", "D102", "D103", "ANN", "S101"] +"examples/**/*.py" = ["D100", "D103", "D104", "ANN", "INP001", "T201"] + +[tool.pyright] +reportInvalidTypeForm = "none" diff --git a/src/strands_compose/__init__.py b/src/strands_compose/__init__.py new file mode 100644 index 0000000..43c835c --- /dev/null +++ b/src/strands_compose/__init__.py @@ -0,0 +1,64 @@ +"""strands-compose — Zero-code YAML-driven agent orchestration over strands-agents.""" + +from __future__ import annotations + +from .config import ( + AppConfig, + ConfigInput, + ResolvedConfig, + ResolvedInfra, + load, + load_config, + load_session, + resolve_infra, +) +from .config.resolvers.orchestrations import OrchestrationBuilder +from .exceptions import ( + CircularDependencyError, + ConfigurationError, + ImportResolutionError, + SchemaValidationError, + UnresolvedReferenceError, +) +from .hooks import EventPublisher, MaxToolCallsGuard, StopGuard, ToolNameSanitizer +from .mcp import MCPLifecycle, create_mcp_client, create_mcp_server +from .renderers import AnsiRenderer +from .tools import ( + node_as_async_tool, + node_as_tool, +) +from .types import EventType, StreamEvent +from .utils import cli_errors +from .wire import EventQueue, make_event_queue + +__all__ = [ + "AnsiRenderer", + "AppConfig", + "CircularDependencyError", + "ConfigInput", + "ConfigurationError", + "EventPublisher", + "EventQueue", + "EventType", + "ImportResolutionError", + "MCPLifecycle", + "MaxToolCallsGuard", + "OrchestrationBuilder", + "ResolvedConfig", + "ResolvedInfra", + "SchemaValidationError", + "StopGuard", + "StreamEvent", + "ToolNameSanitizer", + "UnresolvedReferenceError", + "cli_errors", + "create_mcp_client", + "create_mcp_server", + "load", + "load_config", + "load_session", + "make_event_queue", + "node_as_async_tool", + "node_as_tool", + "resolve_infra", +] diff --git a/src/strands_compose/config/__init__.py b/src/strands_compose/config/__init__.py new file mode 100644 index 0000000..f6081bb --- /dev/null +++ b/src/strands_compose/config/__init__.py @@ -0,0 +1,53 @@ +"""YAML configuration loading, validation, and resolution.""" + +from __future__ import annotations + +from .interpolation import interpolate, strip_anchors +from .loaders import ConfigInput, load, load_config, load_session +from .resolvers import ResolvedConfig, ResolvedInfra, resolve_infra +from .schema import ( + COLLECTION_KEYS, + JOINT_NAMESPACES, + AgentDef, + AppConfig, + ConversationManagerDef, + DelegateConnectionDef, + DelegateOrchestrationDef, + GraphEdgeDef, + GraphOrchestrationDef, + HookDef, + MCPClientDef, + MCPServerDef, + ModelDef, + OrchestrationDef, + SessionManagerDef, + SwarmOrchestrationDef, +) + +__all__ = [ + "AgentDef", + "AppConfig", + "COLLECTION_KEYS", + "ConversationManagerDef", + "JOINT_NAMESPACES", + "ConfigInput", + "DelegateConnectionDef", + "DelegateOrchestrationDef", + "GraphEdgeDef", + "GraphOrchestrationDef", + "HookDef", + "MCPClientDef", + "MCPServerDef", + "ModelDef", + "OrchestrationDef", + "SessionManagerDef", + "SwarmOrchestrationDef", + "ResolvedConfig", + "ResolvedInfra", + "interpolate", + "load", + "load_config", + "load_session", + "resolve_infra", + "strip_anchors", +] diff --git a/src/strands_compose/config/interpolation.py b/src/strands_compose/config/interpolation.py new file mode 100644 index 0000000..053326c --- /dev/null +++ b/src/strands_compose/config/interpolation.py @@ -0,0 +1,196 @@ +"""Docker Compose-style variable interpolation for YAML config values.""" + +from __future__ import annotations + +import os +import re +from typing import Any + +_VAR_PATTERN = re.compile(r"\$\{([^}]+)\}") + + +def interpolate( + raw: dict[str, Any], + *, + variables: dict[str, Any] | None = None, + env: dict[str, str] | None = None, +) -> dict[str, Any]: + """Interpolate ${VAR} and ${VAR:-default} references in a YAML config dict. + + Lookup order: variables dict -> env dict -> default value -> raise error. + + Uses a two-pass strategy to resolve cross-variable references inside the + ``vars:`` block before interpolating the rest of the config. Pass 1 + resolves each var against env only; Pass 2 resolves each var sequentially + against the pass-1 results so chains like ``B: "${A}y"`` work correctly. + Any ``${VAR}`` pattern still present after two passes indicates a circular + or undefined reference and raises ``ValueError``. + + Args: + raw: Raw parsed YAML dict (will not be mutated — returns a new dict). + variables: User-defined variables (from the ``vars:`` block in YAML). + Values may be any type; non-string values are preserved when the + entire string is a single ``${VAR}`` reference. + env: Environment variables (defaults to ``os.environ`` if None). + + Returns: + New dict with all string values interpolated. + + Raises: + ValueError: If a variable is referenced but not found and has no + default, or if a circular reference is detected in the vars block. + """ + resolved_vars = dict(variables or {}) + resolved_env = env if env is not None else dict(os.environ) + + # Pass 1: resolve vars against env only (lenient — keeps ${VAR} if absent) + resolved_vars = {k: _walk_lenient(v, {}, resolved_env) for k, v in resolved_vars.items()} + + # Pass 2: resolve vars sequentially so each resolved var is immediately + # available to subsequent entries (handles chains like A -> B -> C). + pass2: dict[str, Any] = {} + for k, v in resolved_vars.items(): + pass2[k] = _walk_lenient(v, pass2, resolved_env) + resolved_vars = pass2 + + # Validate: remaining ${...} means circular or undefined reference. + for val in resolved_vars.values(): + if isinstance(val, str): + m = _VAR_PATTERN.search(val) + if m: + var_name = m.group(1).split(":-")[0] + raise ValueError( + f"Unresolved variable reference '${{{var_name}}}' in vars block.\n" + f"Check for circular references or undefined variables." + ) + + return _walk(raw, resolved_vars, resolved_env) + + +def strip_anchors(raw: dict[str, Any]) -> dict[str, Any]: + """Remove x-* keys (YAML anchor scratch pads) from the top level. + + Args: + raw: Raw parsed YAML dict. + + Returns: + New dict without top-level ``x-*`` keys. + """ + return {k: v for k, v in raw.items() if not k.startswith("x-")} + + +def _walk( + data: Any, + variables: dict[str, Any], + env: dict[str, str], +) -> Any: + """Recursively walk data and interpolate string values.""" + if isinstance(data, dict): + return {k: _walk(v, variables, env) for k, v in data.items()} + if isinstance(data, list): + return [_walk(item, variables, env) for item in data] + if isinstance(data, str) and "${" in data: + return _interpolate_string(data, variables, env) + return data + + +def _interpolate_string( + value: str, + variables: dict[str, Any], + env: dict[str, str], +) -> Any: + """Interpolate all ${...} patterns in a single string value. + + If the entire string is a single ``${VAR}`` reference, the resolved value + is returned in its original type (e.g. int stays int). Otherwise, all + resolved values are cast to str and concatenated. + """ + match = _VAR_PATTERN.fullmatch(value) + if match is not None: + return _resolve(match.group(1), variables, env) + + def _replacer(m: re.Match[str]) -> str: + return str(_resolve(m.group(1), variables, env)) + + return _VAR_PATTERN.sub(_replacer, value) + + +def _resolve( + expr: str, + variables: dict[str, Any], + env: dict[str, str], +) -> Any: + """Resolve a single variable expression like ``VAR`` or ``VAR:-default``.""" + var_name, *rest = expr.split(":-", 1) + default: str | None = rest[0] if rest else None + + if var_name in variables: + return variables[var_name] + + if var_name in env: + return env[var_name] + + if default is not None: + return default + + raise ValueError( + f"Variable '${{{var_name}}}' is not set in 'vars:' or environment, " + f"and no default was provided.\n" + f"Use ${{{var_name}:-fallback}} to set a fallback value." + ) + + +# --------------------------------------------------------------------------- +# Lenient variants — used only during the vars pre-resolution passes. +# They return the original ${expr} pattern unchanged instead of raising, so +# unresolved references survive to the post-pass validation step. +# --------------------------------------------------------------------------- + + +def _walk_lenient( + data: Any, + variables: dict[str, Any], + env: dict[str, str], +) -> Any: + """Recursively walk data and interpolate strings; leaves ${VAR} unchanged if unresolved.""" + if isinstance(data, dict): + return {k: _walk_lenient(v, variables, env) for k, v in data.items()} + if isinstance(data, list): + return [_walk_lenient(item, variables, env) for item in data] + if isinstance(data, str) and "${" in data: + return _interpolate_string_lenient(data, variables, env) + return data + + +def _interpolate_string_lenient( + value: str, + variables: dict[str, Any], + env: dict[str, str], +) -> Any: + """Interpolate ${...} patterns; returns ${expr} unchanged if variable not found.""" + match = _VAR_PATTERN.fullmatch(value) + if match is not None: + return _resolve_lenient(match.group(1), variables, env) + + def _replacer(m: re.Match[str]) -> str: + return str(_resolve_lenient(m.group(1), variables, env)) + + return _VAR_PATTERN.sub(_replacer, value) + + +def _resolve_lenient( + expr: str, + variables: dict[str, Any], + env: dict[str, str], +) -> Any: + """Resolve a single variable expression; returns ${expr} unchanged if not found.""" + var_name, *rest = expr.split(":-", 1) + default: str | None = rest[0] if rest else None + + if var_name in variables: + return variables[var_name] + if var_name in env: + return env[var_name] + if default is not None: + return default + return f"${{{expr}}}" diff --git a/src/strands_compose/config/loaders/__init__.py b/src/strands_compose/config/loaders/__init__.py new file mode 100644 index 0000000..a80324a --- /dev/null +++ b/src/strands_compose/config/loaders/__init__.py @@ -0,0 +1,12 @@ +"""YAML config loading — parse, validate, and resolve to live objects.""" + +from __future__ import annotations + +from .loaders import ConfigInput, load, load_config, load_session + +__all__ = [ + "ConfigInput", + "load", + "load_config", + "load_session", +] diff --git a/src/strands_compose/config/loaders/helpers.py b/src/strands_compose/config/loaders/helpers.py new file mode 100644 index 0000000..4b57028 --- /dev/null +++ b/src/strands_compose/config/loaders/helpers.py @@ -0,0 +1,406 @@ +"""Internal helpers — parsing, sanitizing, and merging raw YAML dicts.""" + +from __future__ import annotations + +import logging +import os +import re +from collections.abc import Callable +from pathlib import Path +from typing import Any, cast + +import yaml + +from ...exceptions import ConfigurationError +from ..interpolation import interpolate, strip_anchors +from ..schema import ( + COLLECTION_KEYS, + DelegateOrchestrationDef, + GraphOrchestrationDef, + SwarmOrchestrationDef, +) + +logger = logging.getLogger(__name__) + +# Valid config name: alphanumeric, underscores, hyphens, max 64 chars. +_VALID_NAME_RE = re.compile(r"^[a-zA-Z0-9_\-]{1,64}$") + + +def sanitize_name(name: str) -> str: + """Sanitize a config key to ``[a-zA-Z0-9_\\-]{1,64}``.""" + sanitized = re.sub(r"[^a-zA-Z0-9_\-]", "_", name) + sanitized = re.sub(r"_+", "_", sanitized) + sanitized = sanitized.strip("_") + if len(sanitized) > 64: + logger.warning( + "name=<%s>, truncated=<%s> | name truncated to 64 characters", + name, + sanitized[:64], + ) + sanitized = sanitized[:64] + return sanitized + + +def sanitize_collection_keys(raw: dict) -> None: + """Sanitize dictionary keys in all collection sections (in place).""" + rename_map: dict[str, str] = {} + + for section_key in COLLECTION_KEYS: + section = raw.get(section_key) + if not isinstance(section, dict): + continue + + new_section: dict = {} + for original_name, definition in section.items(): + sanitized = ( + sanitize_name(original_name) + if not _VALID_NAME_RE.match(original_name) + else original_name + ) + + if not sanitized: + raise ValueError( + f"Name '{original_name}' in '{section_key}' is empty after sanitization." + ) + if sanitized in new_section: + raise ValueError( + f"Duplicate name in '{section_key}' after sanitization: " + f"'{original_name}' -> '{sanitized}' collides with an " + f"existing entry." + ) + + if sanitized == original_name: + new_section[original_name] = definition + continue + + logger.warning( + "section=<%s>, original=<%s>, sanitized=<%s> | sanitized collection key", + section_key, + original_name, + sanitized, + ) + rename_map[original_name] = sanitized + new_section[sanitized] = definition + + raw[section_key] = new_section + + if rename_map: + update_references(raw, rename_map) + + +def update_references(raw: dict, rename_map: dict[str, str]) -> None: + """Update internal name references after key sanitization.""" + + def _rename(name: str) -> str: + return rename_map.get(name, name) + + # 1. Entry reference + if isinstance(raw.get("entry"), str): + raw["entry"] = _rename(raw["entry"]) + + # 2. Agent definitions — model and mcp refs + agents = raw.get("agents", {}) + if isinstance(agents, dict): + for agent_def in agents.values(): + if not isinstance(agent_def, dict): + continue + if isinstance(agent_def.get("model"), str): + agent_def["model"] = _rename(agent_def["model"]) + if isinstance(agent_def.get("mcp"), list): + agent_def["mcp"] = [_rename(m) for m in agent_def["mcp"]] + + # 3. MCP client server references + clients = raw.get("mcp_clients", {}) + if isinstance(clients, dict): + for client_def in clients.values(): + if isinstance(client_def, dict) and isinstance(client_def.get("server"), str): + client_def["server"] = _rename(client_def["server"]) + + # 4. Orchestration definitions — driven by reference_fields() descriptors + _ORCH_DEFS = { + "delegate": DelegateOrchestrationDef, + "swarm": SwarmOrchestrationDef, + "graph": GraphOrchestrationDef, + } + + orchs = raw.get("orchestrations", {}) + if isinstance(orchs, dict): + for orch_def in orchs.values(): + if not isinstance(orch_def, dict): + continue + mode = orch_def.get("mode") + def_cls = _ORCH_DEFS.get(mode) + if def_cls is None: + continue + for path_spec in def_cls.reference_fields(): + _apply_rename(orch_def, path_spec, _rename) + + +def _apply_rename( + data: dict[str, Any], + path_spec: str, + rename_fn: Callable[[str], str], +) -> None: + """Apply a rename function to the value(s) at a JSON path spec. + + Supported path forms: + - ``"field"`` — simple top-level string field + - ``"field[]"`` — each element of a top-level list + - ``"field[].sub"`` — ``sub`` key inside each dict element of ``field`` + + Args: + data: The raw dict to mutate. + path_spec: A simplified JSON path descriptor. + rename_fn: A function mapping old names to new names. + """ + parts = path_spec.split("[]") + head = parts[0] + + if len(parts) == 1: + # Simple field: "entry_name" + if isinstance(data.get(head), str): + data[head] = rename_fn(data[head]) + return + + tail = parts[1].lstrip(".") # e.g. "agent", "from", "to", or "" + collection = data.get(head) + if not isinstance(collection, list): + return + + if not tail: + # List of strings: "agents[]" + data[head] = [rename_fn(item) if isinstance(item, str) else item for item in collection] + else: + # List of dicts with a sub-field: "connections[].agent", "edges[].from" + for item in collection: + if isinstance(item, dict) and tail in item: + item[tail] = rename_fn(item[tail]) + + +def is_fs_spec(spec: str) -> bool: + """Return True if ``spec`` looks like a filesystem import path. + + A spec is a filesystem path if the portion before the first ``:`` contains + ``/``, ``\\``, or ends with ``.py``. These are the same rules used by + :func:`resolve_tool_spec` in ``tools.py``. + """ + path_part = spec.partition(":")[0] + return "/" in path_part or "\\" in path_part or path_part.endswith(".py") + + +def make_absolute(spec: str, config_dir: Path) -> str: + """Rewrite a relative filesystem spec to an absolute path. + + Module specs and already-absolute paths are returned unchanged. + + Args: + spec: Import or filesystem spec string. + config_dir: Directory of the config file (anchor for relative paths). + + Returns: + Spec with the path portion made absolute. + """ + if not is_fs_spec(spec): + return spec + + path_part, sep, rest = spec.partition(":") + # Treat POSIX-style absolute paths (starting with "/") as already absolute on all + # platforms, including Windows where Path("/...").is_absolute() returns False. + if Path(path_part).is_absolute() or path_part.startswith("/"): + return spec + + abs_path = str((config_dir / path_part).resolve()) + return f"{abs_path}{sep}{rest}" if sep else abs_path + + +def rewrite_relative_paths(raw: dict, config_dir: Path) -> None: + """Rewrite all relative filesystem specs to absolute paths (in place). + + Every config field that accepts a ``module.path:Object`` or + ``./file.py:Object`` import spec is rewritten so that relative + filesystem paths are anchored to ``config_dir`` (the directory + containing the YAML config file). This ensures configs work + regardless of the process working directory. + + Fields rewritten: + - ``agents..tools[]`` — tool spec strings + - ``agents..hooks[]`` — string import specs and ``HookDef.type`` + - ``agents..type`` — custom agent factory path + - ``mcp_servers..type`` — MCP server factory path + - ``models..provider`` — custom model class path + - ``session_manager.type`` — custom session manager path + + Args: + raw: Parsed raw config dict (mutated in place). + config_dir: Directory of the config file being parsed. + """ + # ── agents ──────────────────────────────────────────────────────────── + agents = raw.get("agents") + if isinstance(agents, dict): + for agent_def in agents.values(): + if not isinstance(agent_def, dict): + continue + + # tools: list[str] + tools = agent_def.get("tools") + if isinstance(tools, list): + agent_def["tools"] = [ + make_absolute(s, config_dir) if isinstance(s, str) else s for s in tools + ] + + # hooks: list[str | dict] + hooks = agent_def.get("hooks") + if isinstance(hooks, list): + for i, hook in enumerate(hooks): + if isinstance(hook, str): + hooks[i] = make_absolute(hook, config_dir) + elif isinstance(hook, dict): + hook_d = cast(dict[str, Any], hook) + hook_type = hook_d.get("type") + if isinstance(hook_type, str): + hook_d["type"] = make_absolute(hook_type, config_dir) + + # type: str (custom agent factory) + if isinstance(agent_def.get("type"), str): + agent_def["type"] = make_absolute(agent_def["type"], config_dir) + + # ── mcp_servers ─────────────────────────────────────────────────────── + mcp_servers = raw.get("mcp_servers") + if isinstance(mcp_servers, dict): + for server_def in mcp_servers.values(): + if isinstance(server_def, dict) and isinstance(server_def.get("type"), str): + server_def["type"] = make_absolute(server_def["type"], config_dir) + + # ── models (custom provider) ────────────────────────────────────────── + models = raw.get("models") + if isinstance(models, dict): + for model_def in models.values(): + if isinstance(model_def, dict) and isinstance(model_def.get("provider"), str): + model_def["provider"] = make_absolute(model_def["provider"], config_dir) + + # ── session_manager (root-level or per-agent — agent already handled) ─ + sm = raw.get("session_manager") + if isinstance(sm, dict) and isinstance(sm.get("type"), str): + sm["type"] = make_absolute(sm["type"], config_dir) + + # ── orchestrations (hooks + edge conditions on swarm/graph) ───────── + orchestrations = raw.get("orchestrations") + if isinstance(orchestrations, dict): + for orch_def in orchestrations.values(): + if not isinstance(orch_def, dict): + continue + orch_hooks = orch_def.get("hooks") + if isinstance(orch_hooks, list): + for i, hook in enumerate(orch_hooks): + if isinstance(hook, str): + orch_hooks[i] = make_absolute(hook, config_dir) + elif isinstance(hook, dict): + hook_d = cast(dict[str, Any], hook) + hook_type = hook_d.get("type") + if isinstance(hook_type, str): + hook_d["type"] = make_absolute(hook_type, config_dir) + + # edge conditions — rewrite relative file paths + edges = orch_def.get("edges") + if isinstance(edges, list): + for edge in edges: + if isinstance(edge, dict) and isinstance(edge.get("condition"), str): + edge["condition"] = make_absolute(edge["condition"], config_dir) + + +def parse_single_source(source: str | Path) -> dict: + """Parse one config source into a processed raw dict. + + Handles file reading (for Path or existing file-path strings), + anchor stripping, and per-source variable interpolation. Relative + filesystem tool specs are rewritten to absolute paths anchored to the + config file's directory (not the process CWD). + + Args: + source: File path or raw YAML string. + + Returns: + Processed raw config dict. + + Raises: + FileNotFoundError: If source looks like a file path but doesn't exist. + ValueError: If source does not parse to a YAML mapping. + """ + config_dir: Path | None = None + + if isinstance(source, Path): + if not source.exists(): + raise FileNotFoundError(f"Config file not found: {source}") + try: + raw = yaml.safe_load(source.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise ConfigurationError(f"Invalid YAML in {source}: {exc}") from None + config_dir = source.resolve().parent + else: + path = Path(source) + if path.is_file(): + try: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise ConfigurationError(f"Invalid YAML in {path}: {exc}") from None + config_dir = path.resolve().parent + elif path.suffix in (".yaml", ".yml") or os.sep in source or "/" in source: + raise FileNotFoundError(f"Config file not found: {source}") + else: + try: + raw = yaml.safe_load(source) + except yaml.YAMLError as exc: + raise ConfigurationError(f"Invalid YAML in inline content: {exc}") from None + + if not isinstance(raw, dict): + raise ValueError(f"Config must contain a YAML mapping, got {type(raw).__name__}") + + vars_block = raw.pop("vars", {}) + raw = strip_anchors(raw) + raw = interpolate(raw, variables=vars_block, env=dict(os.environ)) + + if config_dir is not None: + rewrite_relative_paths(raw, config_dir) + + return raw + + +def merge_raw_configs(configs: list[dict]) -> dict: + """Merge multiple processed raw-config dicts. + + Collection sections are combined; duplicate keys in the same section + raise ``ValueError``. All other keys use last-wins semantics. + + Args: + configs: List of processed raw config dicts. + + Returns: + Single merged raw config dict. + + Raises: + ValueError: If duplicate names are found across sources. + """ + merged: dict = {} + + for key in COLLECTION_KEYS: + merged[key] = {} + + for cfg in configs: + for key in COLLECTION_KEYS: + section = cfg.pop(key, {}) + if not isinstance(section, dict): + continue + duplicates = set(section) & set(merged[key]) + if duplicates: + raise ValueError( + f"Duplicate names in '{key}' across config sources: {sorted(duplicates)}" + ) + merged[key].update(section) + + merged.update(cfg) + + for key in COLLECTION_KEYS: + if not merged[key]: + del merged[key] + + return merged diff --git a/src/strands_compose/config/loaders/loaders.py b/src/strands_compose/config/loaders/loaders.py new file mode 100644 index 0000000..9091ef0 --- /dev/null +++ b/src/strands_compose/config/loaders/loaders.py @@ -0,0 +1,270 @@ +"""Public loading functions — parse YAML config and resolve to live objects. + +Usage:: + + from strands_compose.config import load + + # Single file + resolved = load("config.yaml") + + # Multiple files (merged) + resolved = load(["agents.yaml", "mcp.yaml"]) + + # Raw YAML string + resolved = load("agents:\\n a:\\n system_prompt: hi") + + with resolved.mcp_lifecycle: + result = resolved.entry("Hello!") + +Key Features: + - Single-file and multi-file config loading with automatic merging + - Per-source variable interpolation and anchor stripping + - Automatic MCP server startup before agent creation + - Session-level isolation for multi-tenant server deployments +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from pydantic import ValidationError + +from ...exceptions import SchemaValidationError +from ..resolvers import ( + ResolvedConfig, + ResolvedInfra, + resolve_agents, + resolve_infra, + resolve_orchestrations, + resolve_session_manager, +) +from ..schema import AppConfig, SwarmOrchestrationDef +from .helpers import merge_raw_configs, parse_single_source, sanitize_collection_keys +from .validators import validate_references + +logger = logging.getLogger(__name__) + +# Single config source: file path (``str`` or ``Path``) or raw YAML string. +ConfigInput = str | Path + + +def normalize(raw: dict) -> dict: + """Run schema version migrations. + + Called before ``AppConfig.model_validate()`` to allow forward-compatible + schema evolution. Currently only version ``"1"`` is supported. + + Args: + raw: The merged raw config dict (will not be mutated). + + Returns: + A copy of ``raw`` with ``version`` normalised to ``"1"``. + + Raises: + ValueError: If ``version`` is not ``"1"``. + """ + raw = dict(raw) # do not mutate the input + version = str(raw.get("version", "1")) + match version: + case "1": + pass # current canonical schema + case _: + raise ValueError( + f"This config declares schema version '{version}', but this " + f"strands-compose version only supports version '1'.\n" + f"Upgrade: pip install --upgrade strands-compose" + ) + raw["version"] = "1" + return raw + + +def load(config: ConfigInput | list[ConfigInput]) -> ResolvedConfig: + """Load config from file(s) or YAML string(s) and resolve to live objects. + + This is the main entry point for zero-code usage. + Accepts a single source or a list of sources. Each source is either + a file path (``str`` or ``Path``) or a raw YAML string. File paths + are detected by checking if the path exists on disk; anything else + is parsed as inline YAML. + + ### Pipeline: + + 1. Parse each source (file read or inline YAML) + 2. Per-source: strip anchors, interpolate variables + 3. Sanitize collection keys (spaces/special chars -> underscores) + 4. Merge sources (if multiple), detect duplicate names + 5. Validate against schema (Pydantic) + 6. Resolve infrastructure (models, MCP, session — pure) + 7. Start MCP servers (so clients can connect) + 8. Create agents (Agent.__init__ auto-starts MCP clients) + 9. Wire orchestration / entry point + + Args: + config: File path, raw YAML string, or list of either. + + Returns: + ResolvedConfig with agents, entry (callable), and mcp_lifecycle. + + Raises: + FileNotFoundError: Config file doesn't exist. + ConfigurationError: Invalid YAML syntax, schema validation failure, or invalid references. + + --- + ## REMARKS: + + When multiple sources are provided, collection sections (``agents``, + ``models``, ``mcp_servers``, ``mcp_clients``, ``orchestrations``) are + merged. Duplicate names within the same section raise ``ValueError``. + Singleton fields (``entry``, ``session_manager``, ``log_level``) use + last-wins semantics. + + **Side effect**: this function starts MCP servers during resolution. + ``Agent.__init__`` auto-starts MCP clients (via ``process_tools()`` -> + ``MCPClient.load_tools()``), and those clients need running servers to + connect to. ``MCPLifecycle.start()`` is called **before** agent + creation to satisfy this dependency. + + ``MCPLifecycle.start()`` is idempotent, so the caller's context + manager (``async with resolved.mcp_lifecycle:``) is a no-op on enter + but **still required for graceful shutdown** — ``__aexit__`` stops + clients first, then servers. + """ + app_config = load_config(config) + + logging.getLogger("strands_compose").setLevel(app_config.log_level.upper()) + + infra = resolve_infra(app_config) + + # Start MCP servers BEFORE creating agents. + # Initializing Agent starts MCP clients, so we need servers up first. + infra.mcp_lifecycle.start() + + return load_session(app_config, infra) + + +def load_config(config: ConfigInput | list[ConfigInput]) -> AppConfig: + """Parse and validate config from file(s) or YAML string(s). + + Accepts a single source or a list. Each source is auto-detected: + + - ``Path`` objects are always treated as file paths. + - ``str`` values are treated as file paths if the file exists on disk; + otherwise they are parsed as inline YAML content. + + When multiple sources are provided, their collection sections + (``agents``, ``models``, ``mcp_servers``, ``mcp_clients``, + ``orchestrations``) are merged. Duplicate names within the same + section raise ``ValueError``. Singleton fields (``entry``, + ``session_manager``, ``log_level``) use last-wins semantics. + + Each source's ``vars:`` block is applied only to that source + (interpolation is per-source). + + Args: + config: File path, raw YAML string, or list of either. + + Returns: + Validated AppConfig instance. + + Raises: + FileNotFoundError: A ``Path`` source doesn't exist. + ConfigurationError: Invalid YAML, schema validation failure, or invalid references. + """ + sources = config if isinstance(config, list) else [config] + raw_configs = [parse_single_source(s) for s in sources] + + for raw in raw_configs: + sanitize_collection_keys(raw) + + merged = merge_raw_configs(raw_configs) if len(raw_configs) > 1 else raw_configs[0] + + normalized = normalize(merged) + try: + app_config = AppConfig.model_validate(normalized) + except ValidationError as exc: + first_error = exc.errors()[0] + field_path = " -> ".join(str(loc) for loc in first_error["loc"]) + raise SchemaValidationError( + f"Invalid config at '{field_path}': {first_error['msg']}\n" + f"Check your YAML configuration file." + ) from None + validate_references(app_config) + + return app_config + + +def load_session( + config: AppConfig, + infra: ResolvedInfra, + *, + session_id: str | None = None, +) -> ResolvedConfig: + """Create agents and orchestration from already-started infrastructure. + + This is the session-level counterpart to :func:`load`. Use it when + you want to share MCP servers across multiple sessions (e.g. one + session per HTTP request) while creating **isolated** agents per + session. + + Typical server pattern:: + + app_config = load_config("config.yaml") + infra = resolve_infra(app_config) + infra.mcp_lifecycle.start() + + # Per request: + resolved = load_session(app_config, infra, session_id="abc") + + ``infra.mcp_lifecycle`` must already be started before calling this. + + Args: + config: The validated AppConfig. + infra: Resolved infrastructure with servers already started. + session_id: Optional runtime session ID. When provided **and** + the config declares a ``session_manager``, a fresh + SessionManager is created with this ID so that each HTTP + session gets its own isolated conversation state. + + Returns: + ResolvedConfig with freshly created agents and entry point. + """ + session_manager = infra.session_manager + if session_id and config.session_manager is not None: + session_manager = resolve_session_manager( + config.session_manager, + session_id_override=session_id, + ) + + # Agents used in Swarm orchestrations cannot have session_manager set. + # Temporary until strands-agents supports swarm agents with session persistence. + swarm_agent_names: set[str] = set() + for orch in config.orchestrations.values(): + if isinstance(orch, SwarmOrchestrationDef): + swarm_agent_names.update(orch.agents) + + agents = resolve_agents( + agent_defs=config.agents, + models=infra.models, + mcp_clients=infra.clients, + session_manager=session_manager, + swarm_agent_names=swarm_agent_names, + ) + orchestrators = resolve_orchestrations( + config, + agents, + agent_defs=config.agents, + models=infra.models, + mcp_clients=infra.clients, + session_manager=session_manager, + ) + + all_nodes = dict(agents) | orchestrators + entry = all_nodes[config.entry] + + return ResolvedConfig( + agents=agents, + orchestrators=orchestrators, + entry=entry, + mcp_lifecycle=infra.mcp_lifecycle, + ) diff --git a/src/strands_compose/config/loaders/validators.py b/src/strands_compose/config/loaders/validators.py new file mode 100644 index 0000000..519763e --- /dev/null +++ b/src/strands_compose/config/loaders/validators.py @@ -0,0 +1,124 @@ +"""Config reference validation — checks cross-references before resolution.""" + +from __future__ import annotations + +from ...exceptions import UnresolvedReferenceError +from ..schema import ( + AppConfig, + DelegateOrchestrationDef, + GraphOrchestrationDef, + SwarmOrchestrationDef, +) + + +def validate_references(config: AppConfig) -> None: + """Validate that all cross-references in the config are resolvable. + + Checks: + - Agent model references exist in config.models + - Agent MCP client references exist in config.mcp_clients + - MCP client server references exist in config.mcp_servers + - Orchestration node references exist in agents or orchestrations + + Args: + config: Validated AppConfig to check. + + Raises: + ValueError: With clear message about what reference is broken. + """ + agent_names = set(config.agents) + orch_names = set(config.orchestrations) + all_node_names = agent_names | orch_names + + for agent_name, agent_def in config.agents.items(): + if isinstance(agent_def.model, str) and agent_def.model not in config.models: + raise UnresolvedReferenceError( + f"Agent '{agent_name}' references model '{agent_def.model}' " + f"which is not defined.\n" + f"Available models: {list(config.models)}" + ) + for mcp_name in agent_def.mcp: + if mcp_name not in config.mcp_clients: + raise UnresolvedReferenceError( + f"Agent '{agent_name}' references MCP client '{mcp_name}' " + f"which is not defined.\n" + f"Available: {list(config.mcp_clients)}" + ) + + for client_name, client_def in config.mcp_clients.items(): + if client_def.server and client_def.server not in config.mcp_servers: + raise UnresolvedReferenceError( + f"MCP client '{client_name}' references server '{client_def.server}' " + f"which is not defined.\n" + f"Available: {list(config.mcp_servers)}" + ) + + for orch_name, orch_def in config.orchestrations.items(): + validate_orchestration_refs(orch_def, all_node_names, orch_name=orch_name) + + +def validate_orchestration_refs( + orchestration: DelegateOrchestrationDef | SwarmOrchestrationDef | GraphOrchestrationDef, + valid_names: set[str], + orch_name: str | None = None, +) -> None: + """Validate that all node references in an orchestration exist. + + Args: + orchestration: The orchestration config section. + valid_names: Set of valid node names (agents + orchestrations). + orch_name: Name of this orchestration (for error messages). + + Raises: + ValueError: With clear message about broken reference. + """ + prefix = f"Orchestration '{orch_name}': " if orch_name else "" + + if isinstance(orchestration, DelegateOrchestrationDef): + if orchestration.entry_name not in valid_names: + raise UnresolvedReferenceError( + f"{prefix}entry_name '{orchestration.entry_name}' is not defined.\n" + f"Available: {sorted(valid_names)}" + ) + for conn in orchestration.connections: + if conn.agent not in valid_names: + raise UnresolvedReferenceError( + f"{prefix}Delegate target '{conn.agent}' is not defined.\n" + f"Available: {sorted(valid_names)}" + ) + if conn.agent == orchestration.entry_name: + raise UnresolvedReferenceError( + f"{prefix}Agent '{orchestration.entry_name}' delegates to itself — " + f"circular delegation is not allowed." + ) + + elif isinstance(orchestration, SwarmOrchestrationDef): + if orchestration.entry_name not in valid_names: + raise UnresolvedReferenceError( + f"{prefix}entry_name '{orchestration.entry_name}' is not defined.\n" + f"Available: {sorted(valid_names)}" + ) + for agent_name in orchestration.agents: + if agent_name not in valid_names: + raise UnresolvedReferenceError( + f"{prefix}Swarm agent '{agent_name}' is not defined.\n" + f"Available: {sorted(valid_names)}" + ) + + elif isinstance(orchestration, GraphOrchestrationDef): + if orchestration.entry_name not in valid_names: + raise UnresolvedReferenceError( + f"{prefix}entry_name '{orchestration.entry_name}' is not defined.\n" + f"Available: {sorted(valid_names)}" + ) + for edge in orchestration.edges: + if edge.from_agent not in valid_names: + raise UnresolvedReferenceError( + f"{prefix}Graph edge source '{edge.from_agent}' is not defined.\n" + f"Available: {sorted(valid_names)}" + ) + if edge.to_agent not in valid_names: + raise UnresolvedReferenceError( + f"{prefix}Graph edge target '{edge.to_agent}' is not defined.\n" + f"Available: {sorted(valid_names)}" + ) diff --git a/src/strands_compose/config/resolvers/__init__.py b/src/strands_compose/config/resolvers/__init__.py new file mode 100644 index 0000000..ba6da3f --- /dev/null +++ b/src/strands_compose/config/resolvers/__init__.py @@ -0,0 +1,28 @@ +"""Config resolvers package -- __all__ is the single source of truth.""" + +from __future__ import annotations + +from .agents import resolve_agents +from .config import ResolvedConfig, ResolvedInfra, resolve_infra +from .conversation_manager import resolve_conversation_manager +from .hooks import resolve_hook, resolve_hook_entry +from .mcp import resolve_mcp_client, resolve_mcp_server, resolve_tools +from .models import resolve_model +from .orchestrations import resolve_orchestrations +from .session_manager import resolve_session_manager + +__all__ = [ + "ResolvedConfig", + "ResolvedInfra", + "resolve_agents", + "resolve_conversation_manager", + "resolve_infra", + "resolve_hook", + "resolve_hook_entry", + "resolve_mcp_client", + "resolve_mcp_server", + "resolve_model", + "resolve_orchestrations", + "resolve_session_manager", + "resolve_tools", +] diff --git a/src/strands_compose/config/resolvers/agents.py b/src/strands_compose/config/resolvers/agents.py new file mode 100644 index 0000000..106d4ae --- /dev/null +++ b/src/strands_compose/config/resolvers/agents.py @@ -0,0 +1,195 @@ +"""Resolve AgentDef -> strands Agent instances and orchestration.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from strands import Agent + +from ...exceptions import ConfigurationError +from ...utils import load_object +from .conversation_manager import resolve_conversation_manager +from .hooks import resolve_hook_entry +from .mcp import resolve_tools +from .models import resolve_model +from .session_manager import resolve_session_manager + +if TYPE_CHECKING: + from strands.agent.conversation_manager import ConversationManager + from strands.models import Model + from strands.session.session_manager import SessionManager + from strands.tools.mcp import MCPClient as StrandsMCPClient + + from ..schema import AgentDef + +logger = logging.getLogger(__name__) + + +def build_agent_from_def( + name: str, + agent_def: AgentDef, + models: dict[str, Model], + mcp_clients: dict[str, StrandsMCPClient], + session_manager: SessionManager | None, + *, + extra_tools: list[Any] | None = None, + extra_hooks: list[Any] | None = None, + session_manager_override: SessionManager | None = None, + swarm_agent_names: set[str] | None = None, +) -> Agent: + """Build a single Agent from an AgentDef blueprint. + + This is the canonical way to construct an agent from its YAML definition. + Used by both :func:`resolve_agents` (to build all declared agents) and + by :func:`~strands_compose.config.resolvers.orchestrations.builders.build_delegate` + (to fork an agent with delegate tools). + + Args: + name: Agent name / agent_id. + agent_def: The agent's schema definition. + models: Resolved model objects keyed by name. + mcp_clients: Resolved MCP client objects keyed by name. + session_manager: Global session manager (may be inherited). + extra_tools: Additional tools to append (e.g. delegate tools). + extra_hooks: Additional hooks to append (e.g. orchestration-level hooks). + session_manager_override: If set, overrides session manager resolution entirely. + swarm_agent_names: Agent names in swarm orchestrations (fail-fast on SM). + + Returns: + A freshly constructed strands Agent. + + Raises: + ConfigurationError: If a swarm agent has a session manager. + TypeError: If a custom factory doesn't return an Agent. + """ + # 1. Resolve model — inline ModelDef or global name ref + model: Model | None = None + if agent_def.model is not None: + if isinstance(agent_def.model, str): + model = models[agent_def.model] + else: + model = resolve_model(agent_def.model) + + # 2. Resolve tool specs + tools = resolve_tools(agent_def.tools) + + # 3. Resolve hooks — module:ClassName or inline HookDef + hooks = [resolve_hook_entry(h) for h in agent_def.hooks] + + # 4. MCP clients as tool providers + tool_providers: list[Any] = [mcp_clients[n] for n in agent_def.mcp] + + # 5. Resolve session manager + if session_manager_override is not None: + agent_session: SessionManager | None = session_manager_override + else: + explicitly_opted_out = ( + "session_manager" in agent_def.model_fields_set and agent_def.session_manager is None + ) + if agent_def.session_manager is not None: + agent_session = resolve_session_manager(agent_def.session_manager) + elif explicitly_opted_out: + agent_session = None + else: + agent_session = session_manager + + # Fail fast: swarm node agents cannot carry a session manager. + if swarm_agent_names and name in swarm_agent_names and agent_session is not None: + source = ( + "per-agent 'session_manager:' field" + if agent_def.session_manager is not None + else "global 'session_manager:' in config" + ) + raise ConfigurationError( + f"Agent '{name}' is in swarm orchestration and cannot have a session manager " + f"(source: {source}).\n" + "Strands does not yet support session persistence for Swarm node agents.\n" + f"Fix: Add 'session_manager: ~' to agent '{name}' to opt out of the global default." + ) + + # 6. Resolve conversation manager + conversation_manager: ConversationManager | None = None + if agent_def.conversation_manager is not None: + conversation_manager = resolve_conversation_manager(agent_def.conversation_manager) + + # 7. Build the agent + all_tools = tools + tool_providers + (extra_tools or []) + all_hooks = hooks + (extra_hooks or []) + + if agent_def.type is not None: + factory = load_object(agent_def.type, target="agent factory") + agent = factory( + name=name, + agent_id=name, + model=model, + system_prompt=agent_def.system_prompt, + description=agent_def.description, + tools=all_tools, + hooks=all_hooks, + conversation_manager=conversation_manager, + session_manager=agent_session, + **agent_def.agent_kwargs, + ) + else: + agent = Agent( + name=name, + agent_id=name, + model=model, + system_prompt=agent_def.system_prompt, + description=agent_def.description, + tools=all_tools, + hooks=all_hooks, + conversation_manager=conversation_manager, + session_manager=agent_session, + load_tools_from_directory=False, + **agent_def.agent_kwargs, + ) + + if not isinstance(agent, Agent): + raise TypeError( + f"Agent factory for '{name}' returned {type(agent).__name__}, expected strands.Agent." + ) + + return agent + + +def resolve_agents( + agent_defs: dict[str, AgentDef], + models: dict[str, Model], + mcp_clients: dict[str, StrandsMCPClient], + session_manager: SessionManager | None, + swarm_agent_names: set[str] | None = None, +) -> dict[str, Agent]: + """Resolve all agents -- flat, no dependencies between them. + + Each agent is independent. They reference models, MCP clients, and + sessions -- but NOT other agents. Agent-to-agent wiring is handled by + the orchestration step. + + ``hooks`` entries are either import-path strings + (``module.path:ClassName`` or ``./file.py:ClassName``) or inline HookDef + objects -- resolved per-agent so each agent gets fresh hook instances. + + Returns: + Resolved agents dict keyed by name. + + Raises: + ConfigurationError if an agent used in a Swarm orchestration has a session manager assigned. + TypeError if agent factory does not return a strands Agent instance. + """ + resolved: dict[str, Agent] = {} + + for name, agent_def in agent_defs.items(): + agent = build_agent_from_def( + name=name, + agent_def=agent_def, + models=models, + mcp_clients=mcp_clients, + session_manager=session_manager, + swarm_agent_names=swarm_agent_names, + ) + resolved[name] = agent + logger.info("agent=<%s>, type=<%s> | resolved agent", name, agent_def.type or "Agent") + + return resolved diff --git a/src/strands_compose/config/resolvers/config.py b/src/strands_compose/config/resolvers/config.py new file mode 100644 index 0000000..cbecaa6 --- /dev/null +++ b/src/strands_compose/config/resolvers/config.py @@ -0,0 +1,166 @@ +"""ResolvedConfig, ResolvedInfra, and resolve_infra orchestration.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from ...mcp.lifecycle import MCPLifecycle +from ...wire import make_event_queue +from .mcp import resolve_mcp_client, resolve_mcp_server +from .models import resolve_model +from .session_manager import resolve_session_manager + +if TYPE_CHECKING: + from strands import Agent + from strands.models import Model + from strands.session.session_manager import SessionManager + from strands.tools.mcp import MCPClient as StrandsMCPClient + + from ...mcp.server import MCPServer + from ...types import Node + from ...wire import EventQueue + from ..schema import AppConfig + +logger = logging.getLogger(__name__) + + +@dataclass(kw_only=True) +class ResolvedConfig: + """Fully resolved config — lifecycle started, agents ready. + + After calling :func:`~strands_compose.config.loaders.load`, use + :meth:`wire_event_queue` to set up event streaming:: + + resolved = load("config.yaml") + event_queue = resolved.wire_event_queue() + """ + + agents: dict[str, Agent] = field(default_factory=dict) + orchestrators: dict[str, Node] = field(default_factory=dict) + entry: Node + mcp_lifecycle: MCPLifecycle = field(default_factory=MCPLifecycle) + + def wire_event_queue( + self, + *, + tool_labels: dict[str, str] | None = None, + ) -> EventQueue: + """Wire all agents and orchestrators for event streaming. + + This is the recommended way to set up event streaming. It calls + :func:`~strands_compose.wire.make_event_queue` with this + config's agents and orchestrators. + + .. warning:: + + This **mutates** the agents and orchestrators stored on this + instance by adding hooks and overwriting ``callback_handler``. + Call it only once per ``ResolvedConfig`` instance. + + Args: + tool_labels: Optional tool name -> display label mapping. + + Returns: + A ready-to-use :class:`~strands_compose.wire.EventQueue`. + """ + return make_event_queue( + self.agents, + orchestrators=self.orchestrators, + tool_labels=tool_labels, + ) + + +@dataclass +class ResolvedInfra: + """Infrastructure resolved from config — lifecycle NOT started. + + This is the pure result of :func:`resolve_infra`. Lifecycle is cold, + agents are not yet created. + + Use :func:`~strands_compose.config.loaders.load` for a fully + activated system, or manually:: + + infra = resolve_infra(config) + infra.mcp_lifecycle.start() + agents = resolve_agents(agent_defs=config.agents, ...) + """ + + models: dict[str, Model] = field(default_factory=dict) + clients: dict[str, StrandsMCPClient] = field(default_factory=dict) + session_manager: SessionManager | None = None + mcp_lifecycle: MCPLifecycle = field(default_factory=MCPLifecycle) + + +def resolve_infra(config: AppConfig) -> ResolvedInfra: + """Resolve infrastructure from an AppConfig (pure, no I/O). + + Creates model objects, MCP server/client objects, a lifecycle + manager, and a session manager. Nothing is started. + + Resolution order: + + 1. Models (no dependencies) + 2. MCP servers (no dependencies) + 3. MCP clients (depend on servers) + 4. MCP lifecycle (assembles servers + clients, **not** started) + 5. Session manager (no dependencies) + + Agents and orchestration are resolved in :func:`load` after + ``mcp_lifecycle.start()`` because ``Agent.__init__`` auto-starts + MCP clients which need servers to be running first. The lifecycle + start in ``load()`` is idempotent — the context manager is still + used for graceful shutdown. + + Args: + config: Parsed AppConfig from YAML. + + Returns: + A :class:`ResolvedInfra` with models, clients, session manager, + and a cold MCP lifecycle. + """ + # 1. Models + models: dict[str, Model] = {} + for name, model_def in config.models.items(): + models[name] = resolve_model(model_def) + logger.info("model=<%s>, provider=<%s> | resolved model", name, model_def.provider) + + # 2. MCP servers + servers: dict[str, MCPServer] = {} + for name, server_def in config.mcp_servers.items(): + servers[name] = resolve_mcp_server(server_def, name=name) + logger.info("server=<%s> | resolved MCP server", name) + + # 3. MCP clients (resolved but NOT started) + clients: dict[str, StrandsMCPClient] = {} + for name, client_def in config.mcp_clients.items(): + clients[name] = resolve_mcp_client(client_def, servers, name=name) + logger.info("client=<%s> | resolved MCP client", name) + + # 4. MCP lifecycle (cold — not started) + lifecycle = MCPLifecycle() + for name, server in servers.items(): + lifecycle.add_server(name, server) + for name, client in clients.items(): + lifecycle.add_client(name, client) + + # 5. Session manager + session_manager: SessionManager | None = None + if config.session_manager is not None: + # Prevent AgentCoreMemorySessionManager from being set globally + # Required "actor_id" param makes it unsuitable for global config + if config.session_manager.provider.lower() == "agentcore": + raise ValueError( + "The 'agentcore' session manager cannot be set globally.\n" + "Configure it for each agent - required 'actor_id' param." + ) + session_manager = resolve_session_manager(config.session_manager) + logger.info("provider=<%s> | resolved session manager", config.session_manager.provider) + + return ResolvedInfra( + models=models, + clients=clients, + session_manager=session_manager, + mcp_lifecycle=lifecycle, + ) diff --git a/src/strands_compose/config/resolvers/conversation_manager.py b/src/strands_compose/config/resolvers/conversation_manager.py new file mode 100644 index 0000000..d042c65 --- /dev/null +++ b/src/strands_compose/config/resolvers/conversation_manager.py @@ -0,0 +1,54 @@ +"""Resolve ConversationManagerDef -> strands ConversationManager instance.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from strands.agent.conversation_manager import ConversationManager + +from ...utils import load_object + +if TYPE_CHECKING: + from ..schema import ConversationManagerDef + + +def resolve_conversation_manager(cm_def: ConversationManagerDef) -> ConversationManager: + """Resolve a ConversationManagerDef to a ConversationManager instance. + + ``type`` must be one of: + + - ``"module.path:ClassName"`` -- full import path (e.g. + ``"strands.agent:SlidingWindowConversationManager"``) + - ``"./path/to/file.py:ClassName"`` -- file-based import + + No short-name aliases are supported. Use the full import path so that + custom and third-party managers work without ambiguity. + + Args: + cm_def: Conversation manager definition from YAML. + + Returns: + Instantiated ConversationManager. + + Raises: + ValueError: If ``type`` is not in ``module:Class`` format. + TypeError: If the resolved object is not a ConversationManager subclass. + """ + type_str = cm_def.type + if ":" not in type_str: + raise ValueError( + f"Conversation manager type {type_str!r} is not a valid import spec.\n" + f"Use 'module.path:ClassName' (e.g. " + f"'strands.agent:SlidingWindowConversationManager') " + f"or './path/to/file.py:ClassName'." + ) + + cls = load_object(type_str, target="conversation manager") + + manager = cls(**cm_def.params) + if not isinstance(manager, ConversationManager): + raise TypeError( + f"Conversation manager {type_str!r} returned {type(manager).__name__}, " + f"expected ConversationManager subclass." + ) + return manager diff --git a/src/strands_compose/config/resolvers/hooks.py b/src/strands_compose/config/resolvers/hooks.py new file mode 100644 index 0000000..57b12e2 --- /dev/null +++ b/src/strands_compose/config/resolvers/hooks.py @@ -0,0 +1,68 @@ +"""Resolve HookDef / HookEntry -> strands HookProvider instance.""" + +from __future__ import annotations + +from strands.hooks import HookProvider + +from ...utils import load_object +from ..schema import HookDef + + +def resolve_hook(hook_def: HookDef) -> HookProvider: + """Resolve a HookDef to a HookProvider instance. + + ``type`` must be one of: + + - ``"module.path:ClassName"`` -- full import path (e.g. + ``"strands_compose.hooks:StopGuard"``) + - ``"./path/to/hooks.py:ClassName"`` -- file-based import + + No short-name aliases are supported. Use the full import path so that + submodules and third-party hooks work without ambiguity. + + Args: + hook_def: Hook definition from YAML. + + Returns: + Instantiated HookProvider. + + Raises: + ValueError: If ``type`` is not in ``module:Class`` format. + TypeError: If the resolved object is not a HookProvider subclass. + """ + + type_str = hook_def.type + if ":" not in type_str: + raise ValueError( + f"Hook type {type_str!r} is not a valid import spec.\n" + f"Use 'module.path:ClassName' (e.g. 'strands_compose.hooks:StopGuard') " + f"or './path/to/file.py:ClassName'." + ) + + cls = load_object(type_str, target="hook") + + hook = cls(**hook_def.params) + if not isinstance(hook, HookProvider): + raise TypeError( + f"Hook {type_str!r} returned {type(hook).__name__}, expected HookProvider subclass." + ) + return hook + + +def resolve_hook_entry(entry: HookDef | str) -> HookProvider: + """Resolve a single hook entry from an AgentDef. + + Accepts either: + + - A **string** -- treated directly as a ``module:ClassName`` or + ``./file.py:ClassName`` import spec. + - An inline **HookDef** -- resolved via its ``type`` and ``params``. + + Args: + entry: Import-path string or inline HookDef. + + Returns: + Instantiated HookProvider. + """ + hook_def = HookDef(type=entry) if isinstance(entry, str) else entry + return resolve_hook(hook_def) diff --git a/src/strands_compose/config/resolvers/mcp.py b/src/strands_compose/config/resolvers/mcp.py new file mode 100644 index 0000000..631ceb8 --- /dev/null +++ b/src/strands_compose/config/resolvers/mcp.py @@ -0,0 +1,106 @@ +"""Resolve MCPServerDef, MCPClientDef, and tool specs.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, cast + +from ...mcp.client import create_mcp_client +from ...mcp.server import MCPServer +from ...mcp.transports import MCP_TRANSPORT +from ...tools import resolve_tool_specs +from ...utils import load_object + +if TYPE_CHECKING: + from strands.tools.mcp import MCPClient as StrandsMCPClient + + from ..schema import MCPClientDef, MCPServerDef + + +def resolve_tools(tool_specs: list[str]) -> list[Any]: + """Resolve tool specification strings to tool objects. + + Delegates to :func:`resolve_tool_specs` from ``core.tools``, which + understands module paths, file paths, and directory paths. + + Args: + tool_specs: List of tool specification strings. + + Returns: + Flat list of tool objects. + """ + return resolve_tool_specs(tool_specs) + + +def resolve_mcp_server( + server_def: MCPServerDef, + *, + name: str = "", +) -> MCPServer: + """Resolve an MCPServerDef to an MCPServer instance. + + Imports the factory from the full import path and passes + ``server_def.params`` as constructor kwargs. + + Args: + server_def: MCP server definition from YAML. + name: Server name (key under ``mcp_servers:``). + + Returns: + Instantiated MCPServer (not yet started). + + Raises: + ValueError: If the server type cannot be resolved. + TypeError: If the resolved object is not an MCPServer subclass. + """ + factory = load_object(server_def.type, target="MCP server") + server = factory(name=name, **server_def.params) + if not isinstance(server, MCPServer): + raise TypeError( + f"MCP server factory '{server_def.type}' returned {type(server).__name__}, " + f"expected MCPServer subclass." + ) + return server + + +def resolve_mcp_client( + client_def: MCPClientDef, + servers: dict[str, MCPServer], + *, + name: str = "", +) -> StrandsMCPClient: + """Resolve an MCPClientDef to a strands MCPClient. + + Uses :func:`create_mcp_client` from ``core.mcp.client``. + Resolves server reference to actual MCPServer instance. + + Args: + client_def: MCP client definition from YAML. + servers: Already-resolved server instances by name. + name: Client name (key under ``mcp_clients:``). + + Returns: + A strands MCPClient instance (not started). + + Raises: + ValueError: If a server reference cannot be resolved. + """ + server: MCPServer | None = None + if client_def.server: + if client_def.server not in servers: + raise ValueError( + f"MCP client '{name}' references server '{client_def.server}' " + f"which is not defined under mcp_servers:.\n" + f"Available: {', '.join(sorted(servers)) or '(none)'}" + ) + server = servers[client_def.server] + + kwargs: dict[str, Any] = { + "server": server, + "url": client_def.url, + "command": client_def.command, + "transport_options": client_def.transport_options or None, + **client_def.params, + } + if client_def.transport is not None: + kwargs["transport"] = cast(MCP_TRANSPORT, client_def.transport) + return create_mcp_client(**kwargs) diff --git a/src/strands_compose/config/resolvers/models.py b/src/strands_compose/config/resolvers/models.py new file mode 100644 index 0000000..b832a4a --- /dev/null +++ b/src/strands_compose/config/resolvers/models.py @@ -0,0 +1,47 @@ +"""Resolve ModelDef -> strands model instance.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from strands.models import Model + +from ...models import PROVIDERS, create_model +from ...utils import load_object + +if TYPE_CHECKING: + from ..schema import ModelDef + +logger = logging.getLogger(__name__) + + +def resolve_model(model_def: ModelDef) -> Model: + """Resolve a ModelDef to a strands model instance. + + Built-in providers (``"ollama"``, ``"bedrock"``, ``"openai"``, + ``"gemini"``) are dispatched via :func:`~strands_compose.models.create_model`. + Any other ``provider`` value is treated as an import spec + (``module.path:ClassName``) for a custom :class:`~strands.models.Model` + subclass. + + Args: + model_def: Parsed model definition from YAML. + + Returns: + A strands-compatible model instance. + + Raises: + ValueError: If the custom model class is not a Model subclass. + ImportError: If a required optional provider package is not installed. + """ + if model_def.provider.lower() in {p.lower() for p in PROVIDERS}: + return create_model(model_def.provider, model_def.model_id, **model_def.params) + + # Custom provider — load class from import spec + model_cls = load_object(model_def.provider, target="model class") + if not issubclass(model_cls, Model): + raise ValueError( + f"Custom model class '{model_def.provider}' must be a subclass of strands.models.Model." + ) + return model_cls(model_id=model_def.model_id, **model_def.params) diff --git a/src/strands_compose/config/resolvers/orchestrations/__init__.py b/src/strands_compose/config/resolvers/orchestrations/__init__.py new file mode 100644 index 0000000..fbbd5db --- /dev/null +++ b/src/strands_compose/config/resolvers/orchestrations/__init__.py @@ -0,0 +1,72 @@ +"""Orchestration package — wires agents together in delegate, swarm, or graph mode. + +Supports both flat (single orchestration) and nested (named orchestrations +that reference each other) configurations. Node references in delegate +connections, swarm agents, and graph edges can point to either an agent name +or a named orchestration. + +Submodules +---------- +_tools node_as_tool / node_as_async_tool wrappers +_builders Mode-specific builders (delegate, swarm, graph) +_planner Dependency resolution & multi-orchestration build +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from ....tools import node_as_async_tool, node_as_tool +from .builders import ( + OrchestrationBuilder, + build_delegate, + build_graph, + build_swarm, +) + +if TYPE_CHECKING: + from strands import Agent + from strands.models import Model + from strands.tools.mcp import MCPClient as StrandsMCPClient + + from ....types import Node + from ...schema import AgentDef, AppConfig + from ..session_manager import SessionManager + + +def resolve_orchestrations( + config: AppConfig, + agents: dict[str, Agent], + agent_defs: dict[str, AgentDef], + models: dict[str, Model], + mcp_clients: dict[str, StrandsMCPClient], + session_manager: SessionManager | None = None, +) -> dict[str, Node]: + """Build all named orchestrations from config. + + Returns: + Dict of orchestration name -> built Swarm/Graph/Agent. + Empty dict when no orchestrations are defined. + """ + if not config.orchestrations: + return {} + + return OrchestrationBuilder( + config.orchestrations, + agents, + agent_defs, + models, + mcp_clients, + session_manager, + ).build_all() + + +__all__ = [ + "OrchestrationBuilder", + "build_delegate", + "build_graph", + "build_swarm", + "node_as_async_tool", + "node_as_tool", + "resolve_orchestrations", +] diff --git a/src/strands_compose/config/resolvers/orchestrations/builders.py b/src/strands_compose/config/resolvers/orchestrations/builders.py new file mode 100644 index 0000000..d2ba769 --- /dev/null +++ b/src/strands_compose/config/resolvers/orchestrations/builders.py @@ -0,0 +1,352 @@ +"""Mode-specific orchestration builders — delegate, swarm, graph, and all. + +Each builder takes a typed config, a node pool, and an entry name, +then returns the appropriate callable entry point. +``OrchestrationBuilder`` drives the full multi-orchestration build pipeline. +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from strands import Agent +from strands.multiagent import GraphBuilder, Swarm + +from ....exceptions import ConfigurationError +from ....tools import node_as_async_tool +from ....utils import load_object +from ...schema import ( + DelegateOrchestrationDef, + GraphOrchestrationDef, + OrchestrationDef, + SwarmOrchestrationDef, +) +from ..agents import build_agent_from_def +from ..hooks import resolve_hook_entry +from ..session_manager import resolve_session_manager +from .planner import topological_sort + +if TYPE_CHECKING: + from strands.models import Model + from strands.multiagent.graph import Graph + from strands.tools.mcp import MCPClient as StrandsMCPClient + + from ....types import Node + from ...schema import AgentDef + from ..session_manager import SessionManager + +logger = logging.getLogger(__name__) + + +class OrchestrationBuilder: + """Builds all named orchestrations in dependency order.""" + + def __init__( + self, + configs: dict[str, OrchestrationDef], + agents: dict[str, Agent], + agent_defs: dict[str, AgentDef], + models: dict[str, Model], + mcp_clients: dict[str, StrandsMCPClient], + session_manager: SessionManager | None = None, + ) -> None: + """Initialize the OrchestrationBuilder. + + Orchestrations are sorted topologically so that each orchestration's + dependencies (other orchestrations it references) are built first. + The node pool grows as orchestrations are built, making earlier results + available to downstream configurations. + + Args: + configs: Orchestration definitions keyed by name. + agents: Already-resolved agents keyed by name. + agent_defs: Agent schema definitions for delegate forking. + models: Resolved model objects keyed by name. + mcp_clients: Resolved MCP client objects keyed by name. + session_manager: Global session manager (may be inherited). + """ + self._configs = configs + self._session_manager = session_manager + self._nodes: dict[str, Node] = dict(agents) + self._built: dict[str, Node] = {} + self._agent_defs = agent_defs + self._models = models + self._mcp_clients = mcp_clients + + def build_all(self) -> dict[str, Node]: + """Build all orchestrations in topological order. + + Returns: + Dict of orchestration name -> built orchestration (Swarm | Graph | Agent). + """ + for name in topological_sort(self._configs): + self._build_one(name) + return self._built + + def _build_one(self, name: str) -> None: + cfg = self._configs[name] + entry_name = self._resolve_entry(name, cfg) + + session_manager: SessionManager | None = self._session_manager + if cfg.session_manager is not None: + session_manager = resolve_session_manager(cfg.session_manager) + + result = self._dispatch(name, cfg, entry_name, session_manager) + self._built[name] = result + self._nodes[name] = result + logger.info("orchestration=<%s>, mode=<%s> | built orchestration", name, cfg.mode) + + def _resolve_entry(self, name: str, cfg: OrchestrationDef) -> str: + entry_name = cfg.entry_name + if entry_name not in self._nodes: + raise ConfigurationError( + f"Orchestration '{name}': entry_name '{entry_name}' is not defined.\n" + f"Available nodes: {sorted(self._nodes)}" + ) + return entry_name + + def _dispatch( + self, + name: str, + cfg: OrchestrationDef, + entry_name: str, + session_manager: SessionManager | None, + ) -> Agent | Swarm | Graph: + if isinstance(cfg, DelegateOrchestrationDef): + return build_delegate( + name, + cfg, + self._nodes, + entry_name, + self._agent_defs, + self._models, + self._mcp_clients, + session_manager=session_manager, + ) + if isinstance(cfg, SwarmOrchestrationDef): + return build_swarm( + name, + cfg, + self._nodes, + entry_name, + session_manager=session_manager, + ) + if isinstance(cfg, GraphOrchestrationDef): + return build_graph( + name, + cfg, + self._nodes, + entry_name, + session_manager=session_manager, + ) + raise ConfigurationError(f"Unknown orchestration config type: {type(cfg).__name__}") + + +def build_delegate( + name: str, + config: DelegateOrchestrationDef, + nodes: dict[str, Node], + entry_name: str, + agent_defs: dict[str, AgentDef], + models: dict[str, Model], + mcp_clients: dict[str, StrandsMCPClient], + session_manager: SessionManager | None = None, +) -> Agent: + """Build delegate orchestration: construct a new Agent with delegate tools. + + A **new** Agent is forked from the entry agent's :class:`AgentDef` blueprint + (model, system_prompt, hooks, tools, etc.). Each connection is wrapped as + an async tool and added to the new agent. The original entry agent is + **never mutated**. + + Args: + name: Orchestration name (becomes the new agent's name and agent_id). + config: Delegate orchestration config with connections. + nodes: Dict of name -> Agent or MultiAgentBase. + entry_name: Name of the entry agent whose blueprint is forked. + agent_defs: All declared agent definitions. + models: Resolved model objects keyed by name. + mcp_clients: Resolved MCP client objects keyed by name. + session_manager: Global session manager (may be inherited). + + Returns: + A **new** Agent with delegate tools registered. + + Raises: + ConfigurationError: If entry_name is not a declared agent. + """ + if entry_name not in agent_defs: + raise ConfigurationError( + f"Delegate entry '{entry_name}' must be a declared agent, " + f"not an orchestration or unknown name.\n" + f"Available agents: {sorted(agent_defs)}" + ) + + # Wrap each connection target as an async delegate tool. + delegate_tools: list[Any] = [] + for conn in config.connections: + target_node = nodes[conn.agent] + delegate_tool = node_as_async_tool( + target_node, + description=conn.description, + ) + delegate_tools.append(delegate_tool) + logger.info("tool=<%s>, orchestration=<%s> | delegate tool prepared", conn.agent, name) + + # Resolve orchestration-level hooks. + orch_hooks = [resolve_hook_entry(h) for h in config.hooks] + + # Apply agent_kwargs override: merge entry kwargs with orchestration kwargs. + entry_def = agent_defs[entry_name] + if config.agent_kwargs: + # Merge: entry kwargs first, orchestration kwargs win on conflict. + entry_def = entry_def.model_copy( + update={"agent_kwargs": {**entry_def.agent_kwargs, **config.agent_kwargs}} + ) + + # Build a NEW agent from the (possibly overridden) blueprint + delegate tools. + agent = build_agent_from_def( + name=name, + agent_def=entry_def, + models=models, + mcp_clients=mcp_clients, + session_manager=session_manager, + extra_tools=delegate_tools, + extra_hooks=orch_hooks, + session_manager_override=session_manager if config.session_manager is not None else None, + ) + + logger.info( + "orchestration=<%s>, entry=<%s>, delegates=<%d> | built delegate orchestration", + name, + entry_name, + len(delegate_tools), + ) + return agent + + +def build_swarm( + name: str, + config: SwarmOrchestrationDef, + nodes: dict[str, Node], + entry_name: str, + session_manager: SessionManager | None = None, +) -> Swarm: + """Build swarm orchestration using strands Swarm. + + All nodes must be plain Agent instances (strands Swarm limitation). + Swarm auto-injects ``handoff_to_agent`` tool. + + Args: + name: Orchestration name (becomes the swarm's id). + config: Swarm orchestration config. + nodes: Dict of name -> Agent or MultiAgentBase. + entry_name: Name of the entry/starting agent. + + Returns: + A strands Swarm instance — callable with ``swarm(task)``. + + Raises: + ConfigurationError: If any referenced node is not an Agent. + """ + from strands import Agent as _Agent + + node_agents = [] + for agent_name in config.agents: + node = nodes[agent_name] + if not isinstance(node, _Agent): + raise ConfigurationError( + f"Swarm node '{agent_name}' must be a plain Agent, " + f"got {type(node).__name__}.\n" + f"Swarm does not support nested orchestrations — use Graph mode instead." + ) + node_agents.append(node) + + entry_agent = nodes[entry_name] + if not isinstance(entry_agent, _Agent): + raise ConfigurationError( + f"Swarm entry '{entry_name}' must be a plain Agent, got {type(entry_agent).__name__}." + ) + + hooks = [resolve_hook_entry(h) for h in config.hooks] + + return Swarm( + id=name, + nodes=node_agents, + entry_point=entry_agent, + max_handoffs=config.max_handoffs, + max_iterations=config.max_iterations, + execution_timeout=config.execution_timeout, + node_timeout=config.node_timeout, + session_manager=session_manager, + hooks=hooks, + ) + + +def build_graph( + name: str, + config: GraphOrchestrationDef, + nodes: dict[str, Node], + entry_name: str, + session_manager: SessionManager | None = None, +) -> Graph: + """Build graph orchestration using strands GraphBuilder. + + Nodes execute in parallel batches based on dependency edges. + Supports conditional edges, cycles (with ``reset_on_revisit``), + and nested orchestrations (Swarm/Graph as graph nodes). + + Args: + name: Orchestration name (becomes the graph's id). + config: Graph orchestration config with edges. + nodes: Dict of name -> Agent or MultiAgentBase. + entry_name: Name of the entry node. + + Returns: + A strands Graph instance — callable with ``graph(task)``. + """ + builder = GraphBuilder() + builder.set_graph_id(name) + if session_manager is not None: + builder.set_session_manager(session_manager) + + referenced_nodes: set[str] = {entry_name} + for edge in config.edges: + referenced_nodes.add(edge.from_agent) + referenced_nodes.add(edge.to_agent) + + for node_name in referenced_nodes: + builder.add_node(nodes[node_name], node_id=node_name) + + for edge_def in config.edges: + condition = None + if edge_def.condition: + condition = load_object(edge_def.condition, target="graph condition") + if not callable(condition): + raise ConfigurationError( + f"Edge condition '{edge_def.condition}' resolved to a " + f"non-callable object ({type(condition).__name__}).\n" + f"Conditions must be callable." + ) + builder.add_edge( + edge_def.from_agent, + edge_def.to_agent, + condition=condition, + ) + + builder.set_entry_point(entry_name) + + if config.hooks: + builder.set_hook_providers([resolve_hook_entry(h) for h in config.hooks]) + + if config.max_node_executions is not None: + builder.set_max_node_executions(config.max_node_executions) + if config.execution_timeout is not None: + builder.set_execution_timeout(config.execution_timeout) + if config.node_timeout is not None: + builder.set_node_timeout(config.node_timeout) + if config.reset_on_revisit: + builder.reset_on_revisit(True) + + return builder.build() diff --git a/src/strands_compose/config/resolvers/orchestrations/planner.py b/src/strands_compose/config/resolvers/orchestrations/planner.py new file mode 100644 index 0000000..b496e92 --- /dev/null +++ b/src/strands_compose/config/resolvers/orchestrations/planner.py @@ -0,0 +1,102 @@ +"""Dependency resolution — topological sort for named orchestrations. + +Provides utilities for ordering named orchestrations and collecting +their node references, used by builders to drive the build pipeline. +""" + +from __future__ import annotations + +import heapq +import logging +from typing import TYPE_CHECKING + +from ....exceptions import CircularDependencyError +from ...schema import ( + DelegateOrchestrationDef, + GraphOrchestrationDef, + SwarmOrchestrationDef, +) + +if TYPE_CHECKING: + from ...schema import OrchestrationDef + +logger = logging.getLogger(__name__) + + +def collect_node_refs(config: OrchestrationDef) -> set[str]: + """Collect all node references from an orchestration config. + + Args: + config: An orchestration definition. + + Returns: + Set of node names referenced by this orchestration. + """ + refs: set[str] = set() + if isinstance(config, DelegateOrchestrationDef): + refs.add(config.entry_name) + for conn in config.connections: + refs.add(conn.agent) + elif isinstance(config, SwarmOrchestrationDef): + refs.update(config.agents) + elif isinstance(config, GraphOrchestrationDef): + for edge in config.edges: + refs.add(edge.from_agent) + refs.add(edge.to_agent) + return refs + + +def topological_sort( + configs: dict[str, OrchestrationDef], +) -> list[str]: + """Sort named orchestrations in dependency order. + + An orchestration *depends on* another orchestration when it references + that orchestration's name as a node. References to plain agents are + not dependencies. + + Args: + configs: Dict of orchestration name -> config. + + Returns: + List of orchestration names in build order (dependencies first). + + Raises: + ConfigurationError: On circular dependencies between orchestrations. + """ + orch_names = set(configs) + deps: dict[str, set[str]] = {} + for name, cfg in configs.items(): + refs = collect_node_refs(cfg) + deps[name] = refs & orch_names + + in_degree: dict[str, int] = {n: 0 for n in orch_names} + for name, dep_set in deps.items(): + for _dep in dep_set: + in_degree[name] += 1 + + queue = sorted(n for n in orch_names if in_degree[n] == 0) + heapq.heapify(queue) + order: list[str] = [] + + dependents: dict[str, list[str]] = {n: [] for n in orch_names} + for name, dep_set in deps.items(): + for dep in dep_set: + dependents[dep].append(name) + + while queue: + node = heapq.heappop(queue) + order.append(node) + for other in dependents[node]: + in_degree[other] -= 1 + if in_degree[other] == 0: + heapq.heappush(queue, other) + + if len(order) != len(orch_names): + remaining = orch_names - set(order) + raise CircularDependencyError( + f"Circular dependency between orchestrations: {sorted(remaining)}.\n" + f"Orchestrations cannot reference each other in a cycle." + ) + + return order diff --git a/src/strands_compose/config/resolvers/session_manager.py b/src/strands_compose/config/resolvers/session_manager.py new file mode 100644 index 0000000..3c82712 --- /dev/null +++ b/src/strands_compose/config/resolvers/session_manager.py @@ -0,0 +1,149 @@ +"""Resolve SessionManagerDef -> strands SessionManager instance.""" + +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING, Any + +from ...utils import load_object + +if TYPE_CHECKING: + from bedrock_agentcore.memory.integrations.strands.session_manager import ( + AgentCoreMemorySessionManager, + ) + from strands.session.session_manager import SessionManager + + from ..schema import SessionManagerDef + + +def _resolve_bedrock_agentcore_session_manager( + params: dict[str, Any], session_id: str +) -> AgentCoreMemorySessionManager: + """Helper to resolve an AgentCoreMemorySessionManager with Bedrock-specific config. + + This is used by resolve_session_manager when the provider is "agentcore". + It extracts the relevant parameters from the config and constructs the + necessary AgentCoreMemoryConfig and AgentCoreMemorySessionManager objects. + + Args: + params: The "params" dict from the SessionManagerDef for an "agentcore" provider. + session_id: The resolved session ID to use for the session manager. + + Returns: + An instance of AgentCoreMemorySessionManager configured with the provided parameters. + + Raises: + ImportError: If ``bedrock_agentcore`` is not installed. + ValueError: If ``actor_id`` is missing from ``params``. + """ + try: + from bedrock_agentcore.memory.integrations.strands.config import ( + AgentCoreMemoryConfig as ACMConfig, + ) + from bedrock_agentcore.memory.integrations.strands.session_manager import ( + AgentCoreMemorySessionManager as ACMManager, + ) + except ImportError: + raise ImportError( + "The 'agentcore' session manager requires the agentcore-memory extra:\n" + " pip install strands-compose[agentcore-memory]" + ) from None + + config_fields = { + "memory_id", + "actor_id", + "retrieval_config", + "batch_size", + "flush_interval_seconds", + "context_tag", + "filter_restored_tool_context", + } + # Require "actor_id" in params. Recommended to use agent name for clarity. + # Agent resolver prevents duplicated agent names, so this ensures uniqueness. + if "actor_id" not in params: + raise ValueError( + "The 'agentcore' session manager requires unique 'actor_id' in params.\n" + "Recommended to use your agent name." + ) + # Split params: AgentCoreMemoryConfig fields vs constructor kwargs. + # Extract session_id from params to avoid duplicate keyword argument + config_params = {k: v for k, v in params.items() if k in config_fields and k != "session_id"} + constructor_params = {k: v for k, v in params.items() if k not in config_fields} + config = ACMConfig(session_id=session_id, **config_params) + return ACMManager(config, **constructor_params) + + +def resolve_session_manager( + session_def: SessionManagerDef, + *, + session_id_override: str | None = None, +) -> SessionManager: + """Resolve a SessionManagerDef to a strands SessionManager. + + Built-in providers: + + - ``"file"`` -> ``strands.session.FileSessionManager`` + - ``"s3"`` -> ``strands.session.S3SessionManager`` + - ``"agentcore"`` -> ``AgentCoreMemorySessionManager`` + + Custom class: set ``type`` to an import path (``"mod:Class"``). The + class must be a subclass of ``strands.session.SessionManager`` and will + be instantiated as ``cls(session_id=, **params)``. When ``type`` + is set, ``provider`` is ignored. + + Session ID resolution (in order): + + 1. ``session_id_override`` (HTTP server runtime session ID) + 2. ``params.session_id`` (from YAML config) + 3. Random UUID (CLI mode — fresh session per run) + + Args: + session_def: Session definition from YAML. + session_id_override: When provided, overrides the session ID. + Used by the server to give each HTTP session its own + isolated session manager. + + Returns: + Configured SessionManager instance. + + Raises: + ValueError: If the provider is unknown. + TypeError: If a custom ``type`` class is not a SessionManager subclass. + """ + from strands.session.session_manager import SessionManager as _SessionManager + + # Session ID resolution: override > params > random UUID + session_id = session_id_override or session_def.params.get("session_id") or str(uuid.uuid4()) + + # Extract session_id from params to avoid duplicate keyword argument + params = {k: v for k, v in session_def.params.items() if k != "session_id"} + + # Custom class takes precedence over built-in provider names + if session_def.type is not None: + cls = load_object(session_def.type, target="session manager") + instance = cls(session_id=session_id, **params) + if not isinstance(instance, _SessionManager): + raise TypeError( + f"Custom session manager '{session_def.type}' must be a subclass of " + f"strands.session.SessionManager, got {type(instance).__name__}." + ) + return instance + + provider = session_def.provider.lower() + + if provider == "file": + from strands.session import FileSessionManager + + return FileSessionManager(session_id=session_id, **params) + + if provider == "s3": + from strands.session import S3SessionManager + + return S3SessionManager(session_id=session_id, **params) + + if provider == "agentcore": + return _resolve_bedrock_agentcore_session_manager(params, session_id) + + raise ValueError( + f"Unknown session provider '{provider}'.\nSupported: 'file', 's3', 'agentcore'." + ) diff --git a/src/strands_compose/config/schema.py b/src/strands_compose/config/schema.py new file mode 100644 index 0000000..b198ef7 --- /dev/null +++ b/src/strands_compose/config/schema.py @@ -0,0 +1,337 @@ +"""Pydantic models for YAML configuration validation. + +Pure data models — no runtime imports (Agent, MCPClient, etc.). +Validation catches user errors at parse time with clear messages. + +Key Features: + - Discriminated union for orchestration modes (delegate, swarm, graph) + - Cross-section name collision detection via joint namespaces + - Reference field descriptors for automated name sanitization + - Inline and named model/hook/session_manager resolution +""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, Field, model_validator + + +class ModelDef(BaseModel): + """LLM model configuration.""" + + provider: str + model_id: str + params: dict[str, Any] = Field(default_factory=dict) + + +class HookDef(BaseModel): + """Hook provider reference. + + ``type`` must be a ``module.path:ClassName`` import path or a + ``./file.py:ClassName`` file-based import path. The resolver raises + ``ValueError`` if there is no colon separator. ``params`` are forwarded + as constructor kwargs. + """ + + type: str + params: dict[str, Any] = Field(default_factory=dict) + + +class ConversationManagerDef(BaseModel): + """Conversation manager configuration. + + ``type`` must be a ``module.path:ClassName`` import path or a + ``./file.py:ClassName`` file-based import path. The resolver raises + ``ValueError`` if there is no colon separator. ``params`` are forwarded + as constructor kwargs. + + Built-in strands classes: + + - ``strands.agent:SlidingWindowConversationManager`` + - ``strands.agent:SummarizingConversationManager`` + - ``strands.agent:NullConversationManager`` + """ + + type: str + params: dict[str, Any] = Field(default_factory=dict) + + +class SessionManagerDef(BaseModel): + """Session manager configuration. + + Built-in providers: ``"file"``, ``"s3"``, ``"agentcore"``. + + For a custom class, set ``type`` to an import path + (``"module.path:ClassName"``). The class must be a subclass of + ``strands.session.SessionManager``. When ``type`` is set, ``provider`` + is ignored. + + Session ID resolution order: + 1. Runtime override (e.g., HTTP session header) + 2. ``params.session_id`` + 3. Random UUID (fresh session per CLI run) + """ + + provider: str = "file" + type: str | None = None + params: dict[str, Any] = Field(default_factory=dict) + + +class MCPServerDef(BaseModel): + """MCP server definition.""" + + type: str + params: dict[str, Any] = Field(default_factory=dict) + + +class MCPClientDef(BaseModel): + """MCP client connection definition. + + Exactly one of ``server``, ``url``, or ``command`` must be set. + + ``params`` are forwarded to strands MCPClient (e.g., startup_timeout, + tool_filters, prefix). ``transport_options`` are forwarded to the + transport factory (e.g., headers, auth, timeout, http_client). + """ + + server: str | None = None + url: str | None = None + command: list[str] | None = None + transport: str | None = None + params: dict[str, Any] = Field(default_factory=dict) + transport_options: dict[str, Any] = Field(default_factory=dict) + + @model_validator(mode="after") + def _exactly_one_connection_mode(self) -> MCPClientDef: + """Validate that exactly one of server/url/command is set.""" + modes = [self.server is not None, self.url is not None, self.command is not None] + count = sum(modes) + if count == 0: + raise ValueError( + "MCPClientDef requires exactly one of 'server', 'url', or 'command'; got none." + ) + if count > 1: + raise ValueError( + "MCPClientDef requires exactly one of 'server', 'url', or 'command'; got multiple." + ) + return self + + +class AgentDef(BaseModel): + """Top-level agent definition. + + All agents are defined flat under the ``agents:`` section. + Multi-agent orchestration is configured separately in the ``orchestrations:`` section. + + ``tools`` accepts spec strings: + + - ``"module.path:function_name"`` — single function from module + - ``"module.path"`` — all ``@tool`` functions in module + - ``"./path/to/file.py:function_name"`` — single function from file + - ``"./path/to/file.py"`` — all ``@tool`` functions in file + - ``"./path/to/dir/"`` — all ``@tool`` functions in directory + + ``hooks`` accepts import-path strings (``"module.path:ClassName"`` or + ``"./file.py:ClassName"``) or inline :class:`HookDef` objects with + explicit type + optional params. + """ + + type: str | None = None + """Custom agent factory import path. + + Format: ``module.path:ClassName`` or ``./file.py:ClassName``. + When set, the factory is called instead of ``strands.Agent()`` directly. + The ``agent_kwargs`` dict is spread as ``**kwargs`` to this factory. + """ + agent_kwargs: dict[str, Any] = Field(default_factory=dict) + """Additional keyword arguments passed to strands.Agent() or custom factory. + + Valid Agent parameters: messages, callback_handler, + record_direct_tool_call, trace_attributes, state, plugins, + structured_output_prompt, structured_output_model, tool_executor, + retry_strategy, concurrent_invocation_mode, load_tools_from_directory. + + Warning: Use at your own risk — no schema-level validation is performed. + Agent.__init__ has 24 explicit parameters and no **kwargs; any invalid + key will raise TypeError at construction time. + """ + model: str | ModelDef | None = None + system_prompt: str | None = None + description: str | None = None + tools: list[str] = Field(default_factory=list) + hooks: list[HookDef | str] = Field(default_factory=list) + mcp: list[str] = Field(default_factory=list) + tool_labels: dict[str, str] = Field(default_factory=dict) + conversation_manager: ConversationManagerDef | None = None + session_manager: SessionManagerDef | None = None + + +# --- Orchestration Models --- # + + +class DelegateConnectionDef(BaseModel): + """A delegation connection: orchestrator calls agent as a tool.""" + + agent: str + description: str + + +class DelegateOrchestrationDef(BaseModel): + """Delegate mode: entry agent calls other agents as tools. + + A **new** Agent is constructed from the ``entry_name`` agent's blueprint + (model, system_prompt, hooks, tools, etc.) with delegate tools added for + each connection. The original agent is never mutated. + + Entry point is explicit via ``entry_name`` (consistent with swarm/graph). + + ``agent_kwargs`` is **merged** over the entry agent's ``agent_kwargs`` — + orchestration values win on conflict, unset keys are inherited. + """ + + mode: Literal["delegate"] = "delegate" + entry_name: str + connections: list[DelegateConnectionDef] + session_manager: SessionManagerDef | None = None + hooks: list[HookDef | str] = Field(default_factory=list) + agent_kwargs: dict[str, Any] = Field(default_factory=dict) + """Merged over the entry agent's ``agent_kwargs`` (orchestration wins). + + Common uses: ``system_prompt``, ``callback_handler``, ``conversation_manager``. + """ + + @classmethod + def reference_fields(cls) -> dict[str, str]: + """Return mapping of JSON paths to reference types for name sanitization.""" + return { + "entry_name": "node", + "connections[].agent": "node", + } + + +class SwarmOrchestrationDef(BaseModel): + """Swarm mode: collaborative handoffs between peer agents. + + Agents transfer control to each other via handoff_to_agent tool. + Uses strands Swarm under the hood. + """ + + mode: Literal["swarm"] = "swarm" + agents: list[str] + entry_name: str + max_handoffs: int = 20 + max_iterations: int = 20 + execution_timeout: float = 900.0 + node_timeout: float = 300.0 + session_manager: SessionManagerDef | None = None + hooks: list[HookDef | str] = Field(default_factory=list) + + @classmethod + def reference_fields(cls) -> dict[str, str]: + """Return mapping of JSON paths to reference types for name sanitization.""" + return { + "entry_name": "node", + "agents[]": "node", + } + + +class GraphEdgeDef(BaseModel): + """An edge in a graph orchestration.""" + + from_agent: str = Field(alias="from") + to_agent: str = Field(alias="to") + condition: str | None = None + model_config = {"populate_by_name": True} + + +class GraphOrchestrationDef(BaseModel): + """Graph mode: DAG-based orchestration with conditional edges. + + Agents execute in parallel batches based on dependency order. + Uses strands Graph under the hood. + """ + + mode: Literal["graph"] = "graph" + edges: list[GraphEdgeDef] + max_node_executions: int | None = None + execution_timeout: float | None = None + node_timeout: float | None = None + reset_on_revisit: bool = False + session_manager: SessionManagerDef | None = None + entry_name: str + hooks: list[HookDef | str] = Field(default_factory=list) + + @classmethod + def reference_fields(cls) -> dict[str, str]: + """Return mapping of JSON paths to reference types for name sanitization.""" + return { + "entry_name": "node", + "edges[].from": "node", + "edges[].to": "node", + } + + +OrchestrationDef = Annotated[ + DelegateOrchestrationDef | SwarmOrchestrationDef | GraphOrchestrationDef, + Field(discriminator="mode"), +] + + +# Sections that hold named dict collections (merged across config sources). +# IMPORTANT: these must exactly match the dict field names on AppConfig below. +COLLECTION_KEYS = ("models", "mcp_servers", "mcp_clients", "agents", "orchestrations") + +# Groups of sections that share a lookup namespace — names must be unique within each group. +# mcp_servers / mcp_clients are independent namespaces and intentionally excluded. +JOINT_NAMESPACES: tuple[tuple[str, ...], ...] = (("agents", "orchestrations"),) + + +class AppConfig(BaseModel): + """Root YAML configuration. + + Orchestrations are defined as a dict of named orchestration blocks + that can reference each other for arbitrary nesting. + """ + + # IF YOU ADD A NEW SECTION, UPDATE: + # 1. COLLECTION_KEYS above (if it's a named dict collection) + # 2. JOINT_NAMESPACES above (if it shares a namespace with another section) + + version: str = "1" + """Schema version — omit to use the default ``"1"``.""" + models: dict[str, ModelDef] = Field(default_factory=dict) + mcp_servers: dict[str, MCPServerDef] = Field(default_factory=dict) + mcp_clients: dict[str, MCPClientDef] = Field(default_factory=dict) + agents: dict[str, AgentDef] = Field(default_factory=dict) + session_manager: SessionManagerDef | None = None + orchestrations: dict[str, OrchestrationDef] = Field(default_factory=dict) + entry: str + log_level: str = "WARNING" + + @model_validator(mode="after") + def _validate_entry_ref(self) -> AppConfig: + """Ensure entry references a defined agent or orchestration.""" + valid_names = set(self.agents) | set(self.orchestrations) + if self.entry not in valid_names: + raise ValueError( + f"entry '{self.entry}' is not defined under agents: or orchestrations:.\n" + f"Available: {', '.join(sorted(valid_names)) or '(none)'}" + ) + return self + + @model_validator(mode="after") + def _validate_no_name_collisions(self) -> AppConfig: + """Ensure no name collisions within shared namespaces (see :data:`JOINT_NAMESPACES`).""" + for namespace in JOINT_NAMESPACES: + entries = [(key, set(getattr(self, key))) for key in namespace] + for i, (section_a, names_a) in enumerate(entries): + for section_b, names_b in entries[i + 1 :]: + overlap = names_a & names_b + if overlap: + raise ValueError( + f"Name collision between {section_a} and {section_b}: " + f"{sorted(overlap)}.\n" + f"Names must be unique within each section." + ) + return self diff --git a/src/strands_compose/converters/__init__.py b/src/strands_compose/converters/__init__.py new file mode 100644 index 0000000..42a15af --- /dev/null +++ b/src/strands_compose/converters/__init__.py @@ -0,0 +1,13 @@ +"""Stream converters for transforming StreamEvents into response formats.""" + +from __future__ import annotations + +from .base import StreamConverter +from .openai import OpenAIStreamConverter +from .raw import RawStreamConverter + +__all__ = [ + "StreamConverter", + "OpenAIStreamConverter", + "RawStreamConverter", +] diff --git a/src/strands_compose/converters/base.py b/src/strands_compose/converters/base.py new file mode 100644 index 0000000..cb9ec52 --- /dev/null +++ b/src/strands_compose/converters/base.py @@ -0,0 +1,54 @@ +"""Abstract base class for StreamEvent converters.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from ..wire import StreamEvent + + +class StreamConverter(ABC): + """Converts StreamEvent objects into protocol-specific output chunks. + + Each converter is stateful across one completion stream (tracks + message id, created timestamp, tool call index, etc.). Create a + new instance per request — do not share across concurrent requests. + """ + + @abstractmethod + def convert(self, event: StreamEvent) -> list[dict[str, Any]]: + """Convert one StreamEvent into zero or more output chunks. + + Returns a list (possibly empty) of serializable dicts. + The transport layer is responsible for serializing and framing + (e.g. 'data: {json}\\n\\n' for SSE). This method returns data + shapes only — never pre-serialized strings. + + Args: + event: The StreamEvent to convert. + + Returns: + A list of serializable dicts representing output chunks. + """ + ... + + @abstractmethod + def done_marker(self) -> str: + """Terminal sentinel to emit after the stream ends. + + E.g. 'data: [DONE]\\n\\n' for OpenAI SSE. + Return empty string if the protocol needs no terminator. + + Returns: + The terminal string to emit, or empty string if none. + """ + ... + + def content_type(self) -> str: + """MIME type for the HTTP streaming response. + + Returns: + The MIME type string. + """ + return "text/event-stream" diff --git a/src/strands_compose/converters/openai.py b/src/strands_compose/converters/openai.py new file mode 100644 index 0000000..4061983 --- /dev/null +++ b/src/strands_compose/converters/openai.py @@ -0,0 +1,221 @@ +"""OpenAI streaming-chunk conversion for LibreChat / OpenWebUI compatibility. + +Converts :class:`~strands_compose.wire.StreamEvent` objects into +OpenAI ``chat.completion.chunk`` dicts suitable for Server-Sent Events. + +**Key structural rules** enforced by the OpenAI SDK and widely adopted by +compatible providers (Together, Groq, DeepSeek, vLLM, …): + +1. Every chunk shares the same ``id`` and ``created`` within one + completion. ``model`` echoes the model name. +2. ``choices`` is a list; each entry has ``index``, ``delta`` (partial + content), and ``finish_reason`` (``null`` until the last chunk). +3. Text tokens -> ``delta.content``. +4. Tool calls -> ``delta.tool_calls`` list of ``{index, id, type, + function: {name, arguments}}`` fragments. Since strands provides + the complete tool input at call time, we emit **one** chunk with the + full name + serialised arguments. +5. Reasoning / chain-of-thought -> ``delta.reasoning_content`` + (DeepSeek convention; supported by most OpenAI-compatible clients). +6. The final content chunk always has ``finish_reason: "stop"``. + The ``"tool_calls"`` value is intentionally not used here — tool + invocations are already complete when the COMPLETE event fires. + OpenAI-compatible clients (LibreChat, OpenWebUI, Continue.dev) + interpret ``"tool_calls"`` as a signal to expect tool outputs, + which would cause an infinite loop. +7. ``usage`` (``prompt_tokens``, ``completion_tokens``, + ``total_tokens``) appears on the last chunk when the provider + supports ``stream_options: {include_usage: true}``. + +Multi-agent events (NODE_START, NODE_STOP, MULTIAGENT_COMPLETE) have +no OpenAI equivalent. They are wrapped in a ``_strands_event`` +extension field so consumers can still observe orchestration flow. + +Usage:: + + converter = OpenAIStreamConverter() + for event in events: + for chunk in converter.convert(event): + yield f"data: {json.dumps(chunk)}\\n\\n" + yield "data: [DONE]\\n\\n" +""" + +from __future__ import annotations + +import json +import time as _time +import uuid +from typing import TYPE_CHECKING, Any + +from ..types import EventType +from .base import StreamConverter + +if TYPE_CHECKING: + from collections.abc import Callable + + from ..wire import StreamEvent + + +class OpenAIStreamConverter(StreamConverter): + """Stateful converter from StreamEvent to OpenAI ``chat.completion.chunk`` dicts.""" + + def __init__(self, completion_id: str | None = None) -> None: + """Initialize the OpenAIStreamConverter. + + Tracks state across a single completion stream to ensure: + + - Consistent ``id`` and ``created`` across all chunks. + - ``role: "assistant"`` emitted only on the first content chunk. + - ``finish_reason`` is always ``"stop"`` on the COMPLETE chunk. + - Tool call index tracking for multi-tool responses. + - ``_has_tool_calls`` is set to ``True`` whenever a TOOL_START + event is seen (useful for logging/metrics). + + Args: + completion_id: Optional completion ID. If not provided, a random + ``chatcmpl-*`` ID is generated. + """ + self._completion_id = completion_id or f"chatcmpl-{uuid.uuid4().hex[:24]}" + self._created = int(_time.time()) + self._sent_role = False + self._has_tool_calls = False + self._tool_call_index = 0 + self._handlers: dict[str, Callable[[StreamEvent], list[dict[str, Any]]]] = { + EventType.TOKEN: self._handle_token, + EventType.REASONING: self._handle_reasoning, + EventType.TOOL_START: self._handle_tool_start, + EventType.TOOL_END: self._handle_tool_end, + EventType.COMPLETE: self._handle_complete, + EventType.ERROR: self._handle_error, + } + + def _base(self, agent_name: str) -> dict[str, Any]: + """Shared skeleton for every ``chat.completion.chunk``.""" + return { + "id": self._completion_id, + "object": "chat.completion.chunk", + "created": self._created, + "model": agent_name, + } + + def convert(self, event: StreamEvent) -> list[dict[str, Any]]: + """Convert a StreamEvent to OpenAI chunk(s). + + Args: + event: The StreamEvent to convert. + + Returns: + A list of ``chat.completion.chunk`` dicts (possibly empty). + """ + handler = self._handlers.get(event.type) + if handler is not None: + return handler(event) + + return [self._passthrough(event)] + + # -- Per-event-type handlers ----------------------------------------------- + + def _handle_token(self, event: StreamEvent) -> list[dict[str, Any]]: + """TOKEN -> ``delta.content``.""" + chunk = self._base(event.agent_name) + delta: dict[str, Any] = {"content": event.data.get("text", "")} + if not self._sent_role: + delta["role"] = "assistant" + self._sent_role = True + chunk["choices"] = [{"index": 0, "delta": delta, "finish_reason": None}] + return [chunk] + + def _handle_reasoning(self, event: StreamEvent) -> list[dict[str, Any]]: + """REASONING -> ``delta.reasoning_content``.""" + chunk = self._base(event.agent_name) + delta: dict[str, Any] = {"reasoning_content": event.data.get("text", "")} + if not self._sent_role: + delta["role"] = "assistant" + self._sent_role = True + chunk["choices"] = [{"index": 0, "delta": delta, "finish_reason": None}] + return [chunk] + + def _handle_tool_start(self, event: StreamEvent) -> list[dict[str, Any]]: + """TOOL_START -> ``delta.tool_calls``. + + Emits a single chunk with the complete function name and + JSON-serialised arguments. + """ + self._has_tool_calls = True + tool_use_id = event.data.get("tool_use_id") or f"call_{uuid.uuid4().hex[:24]}" + chunk = self._base(event.agent_name) + delta: dict[str, Any] = { + "tool_calls": [ + { + "index": self._tool_call_index, + "id": tool_use_id, + "type": "function", + "function": { + "name": event.data.get("tool_name", ""), + "arguments": json.dumps(event.data.get("tool_input", {})), + }, + } + ], + } + if not self._sent_role: + delta["role"] = "assistant" + self._sent_role = True + chunk["choices"] = [{"index": 0, "delta": delta, "finish_reason": None}] + self._tool_call_index += 1 + return [chunk] + + def _handle_tool_end(self, event: StreamEvent) -> list[dict[str, Any]]: + """TOOL_END -> passthrough in ``_strands_event`` extension. + + OpenAI's streaming protocol has no tool-result chunk — results + are submitted by the caller in the next request. We pass + through the event data in the ``_strands_event`` extension field + so clients that understand it can observe tool results. + """ + return [self._passthrough(event)] + + def _handle_error(self, event: StreamEvent) -> list[dict[str, Any]]: + """ERROR -> ``finish_reason: "error"`` + top-level ``error``.""" + chunk = self._base(event.agent_name) + chunk["choices"] = [{"index": 0, "delta": {}, "finish_reason": "error"}] + chunk["error"] = { + "message": event.data.get("message", "An error occurred"), + "type": "agent_error", + } + return [chunk] + + def _handle_complete(self, event: StreamEvent) -> list[dict[str, Any]]: + """COMPLETE -> final chunk with ``finish_reason: "stop"`` and ``usage``. + + Always emits ``finish_reason: "stop"``. Tool invocations are + complete by the time this event fires; using ``"tool_calls"`` + would signal OpenAI-compatible clients to expect pending tool + results and cause an infinite loop. + + ``_has_tool_calls`` is still tracked and available for metrics. + """ + usage_in = event.data.get("usage", {}) + finish_reason = "stop" + chunk = self._base(event.agent_name) + chunk["choices"] = [{"index": 0, "delta": {}, "finish_reason": finish_reason}] + chunk["usage"] = { + "prompt_tokens": usage_in.get("input_tokens", 0), + "completion_tokens": usage_in.get("output_tokens", 0), + "total_tokens": usage_in.get("total_tokens", 0), + } + return [chunk] + + def done_marker(self) -> str: + """Return the OpenAI SSE stream terminator. + + Returns: + The 'data: [DONE]\\n\\n' sentinel string. + """ + return "data: [DONE]\n\n" + + def _passthrough(self, event: StreamEvent) -> dict[str, Any]: + """Wrap unknown/multi-agent events in ``_strands_event`` extension.""" + chunk = self._base(event.agent_name) + chunk["choices"] = [{"index": 0, "delta": {}, "finish_reason": None}] + chunk["_strands_event"] = event.asdict() + return chunk diff --git a/src/strands_compose/converters/raw.py b/src/strands_compose/converters/raw.py new file mode 100644 index 0000000..024388e --- /dev/null +++ b/src/strands_compose/converters/raw.py @@ -0,0 +1,31 @@ +"""Raw pass-through StreamEvent converter.""" + +from __future__ import annotations + +from typing import Any + +from ..wire import StreamEvent +from .base import StreamConverter + + +class RawStreamConverter(StreamConverter): + """Converts StreamEvents to raw JSON dicts (newline-delimited).""" + + def convert(self, event: StreamEvent) -> list[dict[str, Any]]: + """Pass through as dict. + + Args: + event: The StreamEvent to convert. + + Returns: + A single-element list containing the event's dict representation. + """ + return [event.asdict()] + + def done_marker(self) -> str: + """No terminator needed for raw streams. + + Returns: + An empty string. + """ + return "" diff --git a/src/strands_compose/exceptions.py b/src/strands_compose/exceptions.py new file mode 100644 index 0000000..8382bf5 --- /dev/null +++ b/src/strands_compose/exceptions.py @@ -0,0 +1,40 @@ +"""Shared exception types for strands-compose configuration errors. + +Hierarchy +--------- + +:: + + ValueError + └── ConfigurationError — base for all config errors + ├── SchemaValidationError — Pydantic validation failures + ├── UnresolvedReferenceError — missing model/agent/mcp references + ├── CircularDependencyError — cycles in orchestration graphs + └── ImportResolutionError — failed load_object() imports +""" + +from __future__ import annotations + + +class ConfigurationError(ValueError): + """Raised when a strands-compose configuration is invalid. + + Subclasses ``ValueError`` so callers that catch ``ValueError`` still work, + but allows more specific ``except ConfigurationError`` handling. + """ + + +class SchemaValidationError(ConfigurationError): + """Raised when YAML config fails Pydantic schema validation.""" + + +class UnresolvedReferenceError(ConfigurationError): + """Raised when a config references a non-existent model, agent, or MCP resource.""" + + +class CircularDependencyError(ConfigurationError): + """Raised when orchestration definitions contain a dependency cycle.""" + + +class ImportResolutionError(ConfigurationError): + """Raised when a custom import spec (``module.path:Name``) cannot be loaded.""" diff --git a/src/strands_compose/hooks/__init__.py b/src/strands_compose/hooks/__init__.py new file mode 100644 index 0000000..3e0a474 --- /dev/null +++ b/src/strands_compose/hooks/__init__.py @@ -0,0 +1,17 @@ +"""Reusable HookProvider implementations for strands agents.""" + +from __future__ import annotations + +from .event_publisher import EventPublisher +from .max_calls_guard import MaxToolCallsGuard +from .stop_guard import MultiAgentStopGuard, StopGuard, stop_guard_from_event +from .tool_name_sanitizer import ToolNameSanitizer + +__all__ = [ + "EventPublisher", + "MaxToolCallsGuard", + "MultiAgentStopGuard", + "StopGuard", + "ToolNameSanitizer", + "stop_guard_from_event", +] diff --git a/src/strands_compose/hooks/event_publisher.py b/src/strands_compose/hooks/event_publisher.py new file mode 100644 index 0000000..d3c18db --- /dev/null +++ b/src/strands_compose/hooks/event_publisher.py @@ -0,0 +1,378 @@ +"""EventPublisher hook for streaming agent activities to external consumers. + +Key Features: + - Unified single-agent and multi-agent event publishing + - Safe callback wrapping that logs instead of propagating exceptions + - Longest-prefix tool label resolution for display names + - Callback handler factory for TOKEN, REASONING, and HANDOFF events +""" + +from __future__ import annotations + +import asyncio +import logging +import sys +from collections.abc import Callable +from typing import Any + +from strands.hooks import HookProvider, HookRegistry + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +from strands.hooks.events import ( + AfterInvocationEvent, + AfterModelCallEvent, + AfterMultiAgentInvocationEvent, + AfterNodeCallEvent, + AfterToolCallEvent, + BeforeInvocationEvent, + BeforeMultiAgentInvocationEvent, + BeforeNodeCallEvent, + BeforeToolCallEvent, +) + +from ..types import EventType, StreamEvent + +logger = logging.getLogger(__name__) + +EventCallback = Callable[[StreamEvent], None] + +_MAX_RESULT_LEN = 600 + + +# ============================================================================ +# Helpers +# ============================================================================ + + +def _extract_result_text(result: Any, max_len: int = _MAX_RESULT_LEN) -> str | None: + """Extract a plain-text summary from a strands tool result (for streaming TOOL_END).""" + if result is None: + return None + parts: list[str] = [] + for block in result.get("content", []): + if "text" in block: + parts.append(block["text"]) + elif "json" in block: + parts.append(str(block["json"])) + raw = "\n".join(parts) + if not raw: + return None + return raw[:max_len] + "..." if len(raw) > max_len else raw + + +def _resolve_tool_label( + tool_name: str, + labels: dict[str, str] | None = None, +) -> str | None: + """Resolve a tool name to a display label via exact or longest-prefix match.""" + if not labels: + return None + if tool_name in labels: + return labels[tool_name] + best_match = None + best_length = 0 + for prefix, label in labels.items(): + if tool_name.startswith(prefix) and len(prefix) > best_length: + best_match = label + best_length = len(prefix) + return best_match + + +def _safe_callback(callback: EventCallback) -> EventCallback: + """Wrap *callback* so exceptions are logged instead of propagated.""" + + def _wrapper(event: StreamEvent) -> None: + try: + callback(event) + except (RuntimeError, OSError, asyncio.QueueFull): + logger.warning( + "hook=<%s> | event callback raised an exception", "EventPublisher", exc_info=True + ) + except Exception as e: + logger.error( + "hook=<%s> | event callback raised an unexpected exception: %s: %s", + "EventPublisher", + type(e).__name__, + e, + exc_info=True, + ) + raise + + return _wrapper + + +# ============================================================================ +# EventPublisher +# ============================================================================ + + +class EventPublisher(HookProvider): + """Unified event publisher for single-agent and multi-agent orchestrations.""" + + def __init__( + self, + callback: EventCallback, + agent_name: str, + *, + tool_labels: dict[str, str] | None = None, + max_result_len: int = 600, + ) -> None: + """Initialize the EventPublisher. + + Converts strands hook events into :class:`StreamEvent` objects and + delivers them to an external callback. Emits a COMPLETE event at + the end of each invocation with usage metrics from ``EventLoopMetrics``. + + For TOKEN and REASONING events use :meth:`as_callback_handler` to + create a strands-compatible ``callback_handler``. + + Args: + callback: Called with each :class:`StreamEvent`. + agent_name: Identifier for the agent or orchestrator. + tool_labels: Optional mapping of tool names to display labels. + max_result_len: Maximum character length for tool result text + in TOOL_END events. Default: 600. + + Example:: + + publisher = EventPublisher(callback=on_event, agent_name="analyzer") + agent = Agent( + hooks=[publisher], + callback_handler=publisher.as_callback_handler(), + ) + """ + self._callback = _safe_callback(callback) + self._agent_name = agent_name + self._tool_labels = tool_labels or {} + self._max_result_len = max_result_len + self._errored = False + + @override + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + """Register hook callbacks for agent and multiagent events.""" + # Agent-level + registry.add_callback(BeforeInvocationEvent, self._on_agent_start) + registry.add_callback(AfterModelCallEvent, self._on_model_error) + registry.add_callback(BeforeToolCallEvent, self._on_tool_start) + registry.add_callback(AfterToolCallEvent, self._on_tool_end) + registry.add_callback(AfterInvocationEvent, self._on_complete) + # Multiagent-level + registry.add_callback(BeforeNodeCallEvent, self._on_node_start) + registry.add_callback(AfterNodeCallEvent, self._on_node_stop) + registry.add_callback(BeforeMultiAgentInvocationEvent, self._on_multiagent_start) + registry.add_callback(AfterMultiAgentInvocationEvent, self._on_multiagent_complete) + + # -- Agent hooks --------------------------------------------------------- + + def _on_agent_start(self, event: BeforeInvocationEvent) -> None: + """Emit AGENT_START at the beginning of each agent invocation.""" + self._errored = False + self._callback( + StreamEvent( + type=EventType.AGENT_START, + agent_name=self._agent_name, + data={"type": "agent"}, + ), + ) + + def _on_tool_start(self, event: BeforeToolCallEvent) -> None: + """Register a pending tool call and emit a TOOL_START streaming event.""" + raw_name = event.tool_use.get("name", "unknown") + tool_label = _resolve_tool_label(raw_name, self._tool_labels) or raw_name + tool_use_id = event.tool_use.get("toolUseId", "") + + self._callback( + StreamEvent( + type=EventType.TOOL_START, + agent_name=self._agent_name, + data={ + "tool_name": raw_name, + "tool_label": tool_label, + "tool_use_id": tool_use_id, + "tool_input": event.tool_use.get("input", {}), + }, + ), + ) + + def _on_tool_end(self, event: AfterToolCallEvent) -> None: + """Complete a pending tool call, accumulate the step, and emit TOOL_END.""" + raw_name = event.tool_use.get("name", "unknown") + tool_label = _resolve_tool_label(raw_name, self._tool_labels) or raw_name + tool_use_id = event.tool_use.get("toolUseId", "") + + status = "error" if event.exception else "success" + + self._callback( + StreamEvent( + type=EventType.TOOL_END, + agent_name=self._agent_name, + data={ + "tool_name": raw_name, + "tool_label": tool_label, + "tool_use_id": tool_use_id, + "status": status, + "error": str(event.exception) if event.exception else None, + "tool_result": _extract_result_text(event.result, self._max_result_len), + }, + ), + ) + + def _on_complete(self, event: AfterInvocationEvent) -> None: + """Emit COMPLETE with usage metrics from EventLoopMetrics. + + Suppressed when the invocation errored — an ERROR event was + already emitted via :meth:`_on_model_error`. + """ + if self._errored: + return + + metrics = event.agent.event_loop_metrics + + # Usage from the latest invocation (current turn only). + invocation = metrics.latest_agent_invocation + usage = invocation.usage if invocation else metrics.accumulated_usage + + self._callback( + StreamEvent( + type=EventType.COMPLETE, + agent_name=self._agent_name, + data={ + "type": "agent", + "usage": { + "input_tokens": usage.get("inputTokens", 0), + "output_tokens": usage.get("outputTokens", 0), + "total_tokens": usage.get("totalTokens", 0), + }, + }, + ), + ) + + # -- Model error hook ---------------------------------------------------- + + def _on_model_error(self, event: AfterModelCallEvent) -> None: + """Emit ERROR when a model call fails. + + Fires for provider-level exceptions such as expired credentials, + throttling, network errors, or any other model API failure. + Sets ``_errored`` to suppress the subsequent misleading COMPLETE. + """ + if event.exception is None: + return + + self._errored = True + exc = event.exception + self._callback( + StreamEvent( + type=EventType.ERROR, + agent_name=self._agent_name, + data={ + "message": f"{type(exc).__name__}: {exc}", + "exception_type": type(exc).__name__, + }, + ), + ) + + # -- Multiagent hooks ---------------------------------------------------- + + def _on_multiagent_start(self, event: BeforeMultiAgentInvocationEvent) -> None: + """Emit MULTIAGENT_START at the beginning of each multi-agent orchestration.""" + self._callback( + StreamEvent( + type=EventType.MULTIAGENT_START, + agent_name=self._agent_name, + data={ + "multiagent_type": event.source.__class__.__name__.lower(), + }, + ), + ) + + def _on_node_start(self, event: BeforeNodeCallEvent) -> None: + """Emit NODE_START when a multi-agent orchestration begins a node.""" + self._callback( + StreamEvent( + type=EventType.NODE_START, + agent_name=self._agent_name, + data={ + "node_id": event.node_id, + "multiagent_type": event.source.__class__.__name__.lower(), + }, + ), + ) + + def _on_node_stop(self, event: AfterNodeCallEvent) -> None: + """Emit NODE_STOP when a multi-agent orchestration finishes a node.""" + self._callback( + StreamEvent( + type=EventType.NODE_STOP, + agent_name=self._agent_name, + data={ + "node_id": event.node_id, + "multiagent_type": event.source.__class__.__name__.lower(), + }, + ), + ) + + def _on_multiagent_complete(self, event: AfterMultiAgentInvocationEvent) -> None: + """Emit MULTIAGENT_COMPLETE when the orchestration finishes.""" + self._callback( + StreamEvent( + type=EventType.MULTIAGENT_COMPLETE, + agent_name=self._agent_name, + data={ + "multiagent_type": event.source.__class__.__name__.lower(), + }, + ), + ) + + # -- Callback handler for streaming chunks ------------------------------- + + def as_callback_handler(self) -> Callable[..., None]: + """Return a strands-compatible callback_handler for TOKEN, REASONING, and HANDOFF events. + + Handles the following kwarg patterns emitted by strands: + - ``data`` (str): A streamed text chunk -> TOKEN event. + - ``reasoningText`` (str): A reasoning chunk -> REASONING event. + - ``type == "multiagent_handoff"``: A :class:`~strands.types._events.MultiAgentHandoffEvent` + fired during Swarm/Graph node transitions -> HANDOFF event. + + Returns: + A callable compatible with strands ``callback_handler`` interface. + """ + + def _handler(**kwargs: Any) -> None: + text: str = kwargs.get("data", "") + if text: + self._callback( + StreamEvent( + type=EventType.TOKEN, agent_name=self._agent_name, data={"text": text} + ), + ) + + reasoning: str = kwargs.get("reasoningText", "") + if reasoning: + self._callback( + StreamEvent( + type=EventType.REASONING, + agent_name=self._agent_name, + data={"text": reasoning}, + ), + ) + + if kwargs.get("type") == "multiagent_handoff": + self._callback( + StreamEvent( + type=EventType.HANDOFF, + agent_name=self._agent_name, + data={ + "from_node_ids": kwargs.get("from_node_ids", []), + "to_node_ids": kwargs.get("to_node_ids", []), + "message": kwargs.get("message"), + }, + ) + ) + + return _handler diff --git a/src/strands_compose/hooks/max_calls_guard.py b/src/strands_compose/hooks/max_calls_guard.py new file mode 100644 index 0000000..45810c7 --- /dev/null +++ b/src/strands_compose/hooks/max_calls_guard.py @@ -0,0 +1,90 @@ +"""Max tool-calls guard hook. + +Prevents infinite tool-call loops by counting tool invocations per agent +call and stopping the agent when a threshold is reached. Uses strands' +``invocation_state`` dict, which is created fresh for each ``agent()`` call, +so the counter resets automatically between invocations. + +Two-phase shutdown: + +1. **First violation** — cancel the tool call and tell the LLM to stop using + tools and write a final answer. The event loop continues so the LLM gets + one more turn to produce a closing response. +2. **Subsequent violations** — the LLM ignored the warning and tried to call + another tool. Hard-stop the event loop immediately. +""" + +from __future__ import annotations + +import logging +import sys +from typing import Any + +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import BeforeToolCallEvent + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +logger = logging.getLogger(__name__) + + +class MaxToolCallsGuard(HookProvider): + """Stops the agent after a maximum number of tool calls per invocation.""" + + _COUNT_KEY = "max_tool_calls_guard_count" + _LIMIT_HIT_KEY = "max_tool_calls_guard_limit_hit" + + def __init__(self, max_calls: int = 25) -> None: + """Initialize the MaxToolCallsGuard. + + On first violation the LLM is instructed to stop using tools and write a + final answer (graceful shutdown). If the LLM ignores that and requests + another tool call, the event loop is terminated immediately (hard stop). + + Uses strands' ``invocation_state`` dict for per-invocation state — the + counter and flags reset automatically on each new ``agent()`` call. + + Args: + max_calls: Maximum tool calls allowed per invocation. Default: 25. + """ + self.max_calls = max_calls + + @override + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + """Register the BeforeToolCallEvent callback.""" + registry.add_callback(BeforeToolCallEvent, self._on_before_tool) + + def _on_before_tool(self, event: BeforeToolCallEvent) -> None: + """Increment call counter; cancel and warn or hard-stop if limit exceeded.""" + count = event.invocation_state.get(self._COUNT_KEY, 0) + 1 + event.invocation_state[self._COUNT_KEY] = count + + if count <= self.max_calls: + return + + if not event.invocation_state.get(self._LIMIT_HIT_KEY, False): + # First violation — graceful: cancel the tool, let the LLM respond. + logger.warning( + "count=<%d>, limit=<%d> | tool call limit reached, giving LLM one final turn", + count - 1, + self.max_calls, + ) + event.cancel_tool = ( + f"You have reached your tool call limit for this response ({self.max_calls} calls). " + "Do not call any more tools in this response — write your final answer now. " + "The limit resets on the next message and you may use tools freely again." + ) + event.invocation_state[self._LIMIT_HIT_KEY] = True + else: + # LLM ignored the warning — hard stop. + logger.warning( + "limit=<%d> | agent ignored tool call limit, stopping event loop", + self.max_calls, + ) + event.cancel_tool = ( + f"Tool call limit ({self.max_calls}) exceeded. Agent loop terminated." + ) + event.invocation_state.setdefault("request_state", {})["stop_event_loop"] = True diff --git a/src/strands_compose/hooks/stop_guard.py b/src/strands_compose/hooks/stop_guard.py new file mode 100644 index 0000000..aa3a15a --- /dev/null +++ b/src/strands_compose/hooks/stop_guard.py @@ -0,0 +1,113 @@ +"""External stop-signal hooks. + +Allows external code to signal an agent or multi-agent orchestration to +stop processing. :class:`StopGuard` checks at every tool-call boundary; +:class:`MultiAgentStopGuard` checks at every node-call boundary. +""" + +from __future__ import annotations + +import sys +import threading +from typing import TYPE_CHECKING, Any + +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import BeforeNodeCallEvent, BeforeToolCallEvent + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +if TYPE_CHECKING: + from collections.abc import Callable + + +class StopGuard(HookProvider): + """Cancels the agent's event loop when an external stop condition is met.""" + + def __init__(self, stop_check: Callable[[], bool]) -> None: + """Initialize the StopGuard. + + The stop condition is checked before every tool call. When it returns + ``True``, the current tool is cancelled and the event loop is stopped. + + Args: + stop_check: Callable that returns ``True`` when the agent should stop. + Must be thread-safe. Common patterns: + + - ``threading.Event().is_set`` + - ``lambda: some_shared_flag`` + - ``lambda: not process_is_alive()`` + """ + self._should_stop = stop_check + + @override + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + """Register the before-tool-call guard.""" + registry.add_callback(BeforeToolCallEvent, self._on_before_tool) + + def _on_before_tool(self, event: BeforeToolCallEvent) -> None: + """Cancel the tool and stop the event loop if the stop condition is met.""" + if not self._should_stop(): + return + + event.cancel_tool = "Agent stopped by external signal" + event.invocation_state.setdefault("request_state", {})["stop_event_loop"] = True + + +def stop_guard_from_event( + event: threading.Event | None = None, +) -> tuple[StopGuard, threading.Event]: + """Create a StopGuard backed by a ``threading.Event``. + + Convenience factory for the common pattern of using a + ``threading.Event`` as the external stop signal. Can be used to + wire stop-on-disconnect or for programmatic stop control. + + Example:: + + guard, stop = stop_guard_from_event() + agent.hooks.add_hook(guard) + + # later, from any thread: + stop.set() # agent stops at next tool-call boundary + + Args: + event: Optional pre-existing ``threading.Event``. When omitted a + new event is created internally. + + Returns: + Tuple of ``(guard, event)``. Call ``event.set()`` to trigger the stop. + """ + if event is None: + event = threading.Event() + return StopGuard(stop_check=event.is_set), event + + +class MultiAgentStopGuard(HookProvider): + """Cancels node execution when an external stop condition is met.""" + + def __init__(self, stop_check: Callable[[], bool]) -> None: + """Initialize the MultiAgentStopGuard. + + Counterpart to :class:`StopGuard` for multi-agent orchestrations. + Registers a ``BeforeNodeCallEvent`` callback on a Swarm or Graph's + hook registry. When the *stop_check* callable returns ``True``, the + hook sets ``cancel_node`` to prevent the next node from starting. + + Args: + stop_check: Callable returning ``True`` when stop is requested. + Must be thread-safe. + """ + self._stop_check = stop_check + + @override + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + """Register the before-node-call guard.""" + registry.add_callback(BeforeNodeCallEvent, self._on_before_node) + + def _on_before_node(self, event: BeforeNodeCallEvent) -> None: + """Cancel the node if the stop condition is met.""" + if self._stop_check(): + event.cancel_node = "stop requested" diff --git a/src/strands_compose/hooks/tool_name_sanitizer.py b/src/strands_compose/hooks/tool_name_sanitizer.py new file mode 100644 index 0000000..6d06bf6 --- /dev/null +++ b/src/strands_compose/hooks/tool_name_sanitizer.py @@ -0,0 +1,177 @@ +"""Tool-name sanitization hook. + +Some models inject extra tokens into tool names (e.g. ``query<|channel|>commentary`` +instead of ``query``). Strands cannot look up the original tool when that happens, +so the call silently fails. This hook strips the artifacts before Strands does its lookup. + +Two-layer approach: + +1. **AfterModelCallEvent** — rewrites names in the response *before* + Strands does tool lookup. +2. **BeforeToolCallEvent** — safety net: cancels unresolvable garbled names + with a descriptive error message fed back to the LLM. +""" + +from __future__ import annotations + +import logging +import re +import sys +from typing import TYPE_CHECKING, Any + +from strands.hooks import HookProvider, HookRegistry +from strands.hooks.events import AfterModelCallEvent, BeforeToolCallEvent + +if sys.version_info >= (3, 12): + from typing import override +else: + from typing_extensions import override + +if TYPE_CHECKING: + from strands.agent import Agent + +logger = logging.getLogger(__name__) + +# Matches artifact tokens injected by some models (e.g. <|channel|>). +# Used for both detection (.search) and splitting (.split). +_GARBAGE_RE = re.compile(r"[<|>]+") + + +def _find_best_prefix(name: str, allowed: set[str]) -> str | None: + """Return the longest tool name in *allowed* that is a prefix of *name*.""" + best: str | None = None + for tool in allowed: + if name.startswith(tool) and (best is None or len(tool) > len(best)): + best = tool + return best + + +def _sanitize(raw: str, allowed: set[str]) -> str | None: + """Try to recover a valid tool name from a garbled one. + + Strategy: + 1. Exact match -> return as-is. + 2. Prefix match on the raw garbled string (handles ``tool<|garbage``). + 3. Split on garbage chars, rejoin with ``_`` / ``-`` / nothing, + try exact then prefix (handles ``a<|b|>c`` -> ``a_b_c``). + + Returns the corrected name, or ``None`` if no match found. + """ + if raw in allowed: + return raw + + if not _GARBAGE_RE.search(raw): + return None + + best = _find_best_prefix(raw, allowed) + if best: + return best + + # Treat garbage runs as separators and try common join characters. + # e.g. "reporter<|channel|>commentary" -> ["reporter","channel","commentary"] + # -> "reporter_channel_commentary" / "reporter-channel-commentary" / "reporterchannelcommentary" + segments = [s for s in _GARBAGE_RE.split(raw) if s] + for sep in ("_", "-", ""): + candidate = sep.join(segments)[:64] + if candidate in allowed: + return candidate + best = _find_best_prefix(candidate, allowed) + if best: + return best + + return None + + +class ToolNameSanitizer(HookProvider): + """Strips model-injected artifacts from tool names so Strands can look them up. + + Registers on: + - AfterModelCallEvent: rewrites tool names in the model response message. + - BeforeToolCallEvent: safety net — fixes or cancels still-garbled names. + """ + + @override + def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None: + """Register AfterModelCall and BeforeToolCall sanitization callbacks.""" + registry.add_callback(AfterModelCallEvent, self._on_after_model) + registry.add_callback(BeforeToolCallEvent, self._on_before_tool) + + @staticmethod + def _known(agent: Agent) -> set[str]: + """Return tool names from the agent's registry.""" + try: + return set(agent.tool_registry.registry.keys()) + except Exception: + return set() + + # -- Layer 1: fix names in the model response before tool lookup ---------- + + def _on_after_model(self, event: AfterModelCallEvent) -> None: + """Fix garbled tool names in the model's response message.""" + if event.stop_response is None: + return + + content = event.stop_response.message.get("content", []) + if not content: + return + + known = self._known(event.agent) + if not known: + return + + for block in content: + if not isinstance(block, dict) or "toolUse" not in block: + continue + + tool_use = block["toolUse"] + raw: str = tool_use.get("name", "") + + if raw in known: + continue + if not _GARBAGE_RE.search(raw): + continue # clean unknown name — not a garbling issue + + fixed = _sanitize(raw, known) + if fixed: + logger.info( + "original=<%s>, fixed=<%s> | sanitized tool name in model response", raw, fixed + ) + tool_use["name"] = fixed + else: + # Leave garbled name intact so BeforeToolCall can cancel it + # with a descriptive error (listing available tools). + logger.warning( + "name=<%s> | cannot resolve garbled tool name in model response", raw + ) + + # -- Layer 2: safety net before tool execution ---------------------------- + + def _on_before_tool(self, event: BeforeToolCallEvent) -> None: + """Safety net — fix or cancel tool calls with garbled names.""" + raw: str = event.tool_use.get("name", "") + known = self._known(event.agent) + + if raw in known: + return + if not _GARBAGE_RE.search(raw): + return # clean name, not our problem — let Strands handle it + + fixed = _sanitize(raw, known) + if fixed: + logger.info( + "original=<%s>, fixed=<%s> | sanitized tool name before tool call", raw, fixed + ) + event.tool_use["name"] = fixed + try: + tool = event.agent.tool_registry.registry.get(fixed) + if tool: + event.selected_tool = tool + except Exception: # nosec B110 + pass + return + + event.cancel_tool = ( + f"Invalid tool name '{raw}'. " + f"Available tools: {', '.join(sorted(known))}. " + f"Please use an exact tool name from the list above." + ) diff --git a/src/strands_compose/mcp/README.md b/src/strands_compose/mcp/README.md new file mode 100644 index 0000000..0310695 --- /dev/null +++ b/src/strands_compose/mcp/README.md @@ -0,0 +1,105 @@ +# MCP Module — Developer Guide + +This module manages the full lifecycle of [Model Context Protocol](https://modelcontextprotocol.io/) servers and clients within strands-compose. It bridges the gap between the low-level `mcp` Python SDK (which provides `FastMCP` and transport primitives) and the strands agent framework (which consumes `MCPClient` as a tool provider). + +The compose config layer resolves YAML declarations into the objects defined here; this module knows nothing about YAML — it only deals with constructed Python objects and their lifecycle. + +--- + +## server — `MCPServer` and `create_mcp_server` + +**Responsibility:** Define, build, start, and **gracefully stop** MCP tool servers. + +`MCPServer` is an abstract base class. Subclasses implement `_register_tools(mcp)` to register tool functions, custom routes, or resources on the underlying `FastMCP` instance. Everything else — thread management, readiness probing, and shutdown — is handled by the base. + +`create_mcp_server(name, tools=[...])` is a convenience factory that creates an `MCPServer` without subclassing. It's used by YAML configs that list plain callables. + +### Why we bypass `FastMCP.run()` + +`FastMCP.run(transport="streamable-http")` internally does: + +```python +async def run_streamable_http_async(self): + config = uvicorn.Config(self.streamable_http_app(), ...) + server = uvicorn.Server(config) + await server.serve() # blocks forever +``` + +The `uvicorn.Server` instance is a **local variable** — it is never stored on `self`. When running in a background thread, uvicorn cannot install signal handlers (Python restricts `signal.signal()` to the main thread), so there is no way to trigger shutdown from outside. + +Our solution: call `FastMCP.streamable_http_app()` (or `sse_app()`) ourselves to get the Starlette ASGI app, then create and hold our own `uvicorn.Server`. This gives us access to `uvicorn.Server.should_exit` — a boolean that uvicorn's main loop polls every 100 ms. Setting it from any thread triggers graceful shutdown (stop accepting -> drain -> exit). + +### Shutdown sequence + +`stop()` follows a two-phase escalation: + +1. **Graceful** — set `should_exit = True`, wait `STOP_TIMEOUT` (5 s). Uvicorn stops accepting connections and drains in-flight requests. +2. **Forced** — if still alive, set `force_exit = True`, wait `STOP_FORCE_TIMEOUT` (2 s). Uvicorn skips connection draining and exits immediately. +3. **Abandoned** — if the thread is still alive, log a warning. The thread is a daemon and will be reaped at process exit. + +### Transport types + +Only HTTP transports (`streamable-http`, `sse`) are supported for `MCPServer`. The type alias `MCP_SERVER_TRANSPORT = Literal["sse", "streamable-http"]` enforces this at the type level. + +`stdio` is a **client-side** transport where the client spawns a server subprocess and communicates over stdin/stdout pipes. There is no HTTP server to manage, so it doesn't belong in `MCPServer`. Client-side stdio is fully supported via `create_mcp_client(command=...)` and `stdio_transport()`. + +### Subclass contract + +Subclasses only need to implement `_register_tools(mcp: FastMCP)`. Override `run()` for blocking-mode customisation (e.g. the Postgres server adds `finally: close_pools()`). Override `stop()` if you need cleanup after the server thread exits (e.g. closing database pools). + +--- + +## client — `create_mcp_client` + +**Responsibility:** Create a strands `MCPClient` from one of three connection modes. + +Exactly one of these must be provided: + +| Parameter | Transport | Use case | +|-----------|-----------|----------| +| `server=` | streamable-http (default) or sse | Connect to a managed `MCPServer` running in the same process | +| `url=` | Auto-detected from URL path, or explicit override | Connect to an external MCP server | +| `command=` | stdio | Launch a subprocess MCP server | + +The function auto-detects transport from URL paths (e.g. `/sse` -> SSE, everything else -> streamable-http). Transport-specific options (`headers`, `timeout`, `http_client`, etc.) are forwarded via `transport_options`. + +The returned object is a standard strands `MCPClient` — no wrapping, full strands functionality. Strands auto-starts clients when they're registered on an `Agent`, so client start is not managed here. + +--- + +## transports — Transport factory functions + +**Responsibility:** Create transport callables that strands `MCPClient` accepts as `transport_callable`. + +Each factory captures its configuration in a closure and returns a zero-argument callable that produces an async context manager yielding `(read_stream, write_stream)`. This deferred construction matters because strands creates the transport connection lazily when the agent first needs tools. + +Three factories corresponding to the three MCP transport types: + +- **`streamable_http_transport(url, headers=, http_client=)`** — wraps `mcp.client.streamable_http.streamable_http_client`. Supports pre-configured `httpx.AsyncClient` for custom auth/TLS. +- **`sse_transport(url, headers=, timeout=, auth=)`** — wraps `mcp.client.sse.sse_client`. +- **`stdio_transport(command, env=, cwd=)`** — wraps `mcp.client.stdio.stdio_client`. + +--- + +## lifecycle — `MCPLifecycle` + +**Responsibility:** Enforce startup and shutdown ordering across multiple servers and clients. + +The ordering constraint is: + +1. **Start:** all servers start and become ready (TCP port responds) *before* any client can connect. +2. **Stop:** all clients stop *before* any server stops. + +This prevents clients from connecting to servers that aren't ready, and prevents servers from shutting down while clients still have open sessions. + +### Integration with compose + +The config resolver assembles an `MCPLifecycle` with all declared servers and clients, but does **not** start it. The `load()` function calls `lifecycle.start()` before creating agents. Agents auto-start their MCP clients on construction. + +Shutdown happens via context manager (`with lifecycle:` / `async with lifecycle:`) or explicit `lifecycle.stop()` in a `finally` block. + +### Why clients are not started in `lifecycle.start()` + +Strands `MCPClient` manages its own session lifecycle. It starts automatically when registered on an `Agent`. If we started clients in `lifecycle.start()`, the `Agent` constructor would fail with "session is currently running". So `lifecycle.start()` only starts *servers* — clients are left for strands to manage. + +`lifecycle.stop()` does stop clients explicitly because strands does not auto-stop them on agent destruction. diff --git a/src/strands_compose/mcp/__init__.py b/src/strands_compose/mcp/__init__.py new file mode 100644 index 0000000..dcc3ead --- /dev/null +++ b/src/strands_compose/mcp/__init__.py @@ -0,0 +1,27 @@ +"""MCP server and client lifecycle management.""" + +from __future__ import annotations + +from strands.tools.mcp import MCPClient + +from .client import create_mcp_client +from .lifecycle import MCPLifecycle +from .server import MCPServer, create_mcp_server +from .transports import ( + MCP_SERVER_TRANSPORT, + sse_transport, + stdio_transport, + streamable_http_transport, +) + +__all__ = [ + "MCP_SERVER_TRANSPORT", + "MCPClient", + "MCPLifecycle", + "MCPServer", + "create_mcp_client", + "create_mcp_server", + "sse_transport", + "stdio_transport", + "streamable_http_transport", +] diff --git a/src/strands_compose/mcp/client.py b/src/strands_compose/mcp/client.py new file mode 100644 index 0000000..fbad07e --- /dev/null +++ b/src/strands_compose/mcp/client.py @@ -0,0 +1,170 @@ +"""Create strands MCPClient instances from configuration. + +Returns the standard strands MCPClient (which is a ToolProvider). +No wrapping — full strands functionality is available. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from urllib.parse import urlparse + +from .transports import ( + DEFAULT_TRANSPORT, + MCP_TRANSPORT, + sse_transport, + stdio_transport, + streamable_http_transport, +) + +if TYPE_CHECKING: + from strands.tools.mcp import MCPClient + + from .server import MCPServer + + +def create_mcp_client( + *, + server: MCPServer | None = None, + url: str | None = None, + command: list[str] | None = None, + transport: MCP_TRANSPORT = DEFAULT_TRANSPORT, + transport_options: dict[str, Any] | None = None, + **kwargs: Any, +) -> MCPClient: + """Create a strands MCPClient from connection configuration. + + Exactly one of server, url, or command must be provided. + + Args: + server: A managed MCPServer instance (connects via its URL or stdio). + url: External MCP server URL (for SSE or streamable-http). + command: Command to start an MCP server subprocess (stdio transport). + transport: Override transport type ("stdio", "sse", "streamable-http"). + Auto-detected if not specified. + transport_options: Extra kwargs forwarded to the transport factory. + These are transport-specific — see each transport function for + available options: + + **stdio**: ``env``, ``cwd``, ``encoding``, ``encoding_error_handler`` + + **sse**: ``headers``, ``timeout``, ``sse_read_timeout``, ``auth``, + ``httpx_client_factory`` + + **streamable-http**: ``headers``, ``http_client`` (pre-configured + ``httpx.AsyncClient``), ``terminate_on_close`` + + **kwargs: Additional kwargs forwarded to strands MCPClient + (startup_timeout, tool_filters, prefix, elicitation_callback, + tasks_config, etc.). + + Returns: + A strands MCPClient instance. + + Raises: + ValueError: If connection parameters are ambiguous. + """ + modes = sum(x is not None for x in [server, url, command]) + if modes != 1: + raise ValueError( + f"Exactly one of server, url, or command must be provided (got {modes}).\n" + "server=MCPServer for managed servers, url=str for external HTTP, " + "command=list[str] for subprocess stdio." + ) + + opts = transport_options or {} + + if server is not None: + transport_callable = _transport_for_server(server, transport, opts) + elif url is not None: + transport_callable = _transport_for_url(url, transport, opts) + else: + # command is guaranteed non-None by the modes == 1 check above. + transport_callable = stdio_transport(command, **opts) # type: ignore[arg-type] + + return _make_strands_client(transport_callable=transport_callable, **kwargs) + + +def _make_strands_client(**kwargs: Any) -> MCPClient: + """Create a strands MCPClient instance. + + Args: + **kwargs: Arguments forwarded to strands MCPClient constructor. + + Returns: + A strands MCPClient instance. + """ + from strands.tools.mcp import MCPClient as _MCPClient + + return _MCPClient(**kwargs) + + +def _transport_for_server( + server: MCPServer, transport: str | None, opts: dict[str, Any] | None = None +) -> Any: + """Build transport callable for a managed MCPServer. + + Args: + server: The managed MCPServer instance. + transport: Optional transport override. + opts: Transport-specific options forwarded to the transport factory. + + Returns: + A transport callable for strands MCPClient. + + Raises: + ValueError: If the transport type is unsupported for managed servers. + """ + opts = opts or {} + effective = transport or "streamable-http" + if effective == "streamable-http": + return streamable_http_transport(server.url, **opts) + if effective == "sse": + return sse_transport(server.url, **opts) + if effective == "stdio": + raise ValueError( + "stdio transport not supported for managed servers. Use url or command instead." + ) + raise ValueError(f"Unknown transport: {effective}") + + +def _transport_for_url(url: str, transport: str | None, opts: dict[str, Any] | None = None) -> Any: + """Build transport callable for an external URL. + + Args: + url: The external MCP server URL. + transport: Optional transport override. + opts: Transport-specific options forwarded to the transport factory. + + Returns: + A transport callable for strands MCPClient. + + Raises: + ValueError: If the transport type is unsupported for URL connections. + """ + opts = opts or {} + effective = transport or _detect_transport(url) + if effective == "streamable-http": + return streamable_http_transport(url, **opts) + if effective == "sse": + return sse_transport(url, **opts) + raise ValueError( + f"URL-based connection requires 'sse' or 'streamable-http' transport, got: {effective}." + ) + + +def _detect_transport(url: str) -> str: + """Auto-detect transport type from URL. + + Default: streamable-http (the modern MCP transport). + + Args: + url: The MCP server URL. + + Returns: + The detected transport type string. + """ + path = urlparse(url).path.rstrip("/") + if path.endswith("/sse") or path == "/sse": + return "sse" + return "streamable-http" diff --git a/src/strands_compose/mcp/lifecycle.py b/src/strands_compose/mcp/lifecycle.py new file mode 100644 index 0000000..ef3852a --- /dev/null +++ b/src/strands_compose/mcp/lifecycle.py @@ -0,0 +1,233 @@ +"""MCP server and client lifecycle ordering. + +Ensures servers are started and ready before clients connect, +and clients are stopped before servers on shutdown. + +Key Features: + - Ordered startup: servers first, then clients + - Ordered shutdown: clients first, then servers + - Idempotent start with sync and async context managers + - Configurable server readiness timeout +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from types import TracebackType + + from strands.tools.mcp import MCPClient as StrandsMCPClient + + from .server import MCPServer + +logger = logging.getLogger(__name__) + + +class MCPLifecycle: + """Manages MCP server and client lifecycle ordering.""" + + def __init__(self, server_ready_timeout: float = 30) -> None: + """Initialize the MCPLifecycle. + + Ensures servers are fully ready before clients connect, and clients + are stopped before servers on shutdown. + + Example:: + + lifecycle = MCPLifecycle() + lifecycle.add_server("postgres", pg_server) + lifecycle.add_client("pg_client", pg_client) + + with lifecycle: + # All servers started and ready, all clients connected + agent = Agent(tools=[lifecycle.get_client("pg_client")]) + agent("Query the database") + + # All cleaned up + + Or without context manager:: + + lifecycle.start() + try: + ... + finally: + lifecycle.stop() + + Args: + server_ready_timeout: Seconds to wait for each server to become ready. + """ + self._servers: dict[str, MCPServer] = {} + self._clients: dict[str, StrandsMCPClient] = {} + self._server_ready_timeout = server_ready_timeout + self._started = False + + def add_server(self, name: str, server: MCPServer) -> None: + """Register an MCP server. + + Args: + name: Unique server identifier. + server: The MCP server instance. + + Raises: + ValueError: If a server with this name is already registered. + """ + if name in self._servers: + raise ValueError(f"MCP server '{name}' is already registered") + self._servers[name] = server + + def add_client(self, name: str, client: StrandsMCPClient) -> None: + """Register an MCP client. + + Args: + name: Unique client identifier. + client: The strands MCP client instance. + + Raises: + ValueError: If a client with this name is already registered. + """ + if name in self._clients: + raise ValueError(f"MCP client '{name}' is already registered") + self._clients[name] = client + + def get_server(self, name: str) -> MCPServer: + """Get a registered server by name. + + Args: + name: Server identifier. + + Returns: + The registered MCP server. + + Raises: + KeyError: If no server with this name is registered. + """ + if name not in self._servers: + raise KeyError(f"MCP server '{name}' not registered.\nAvailable: {list(self._servers)}") + return self._servers[name] + + def get_client(self, name: str) -> StrandsMCPClient: + """Get a registered client by name. + + Args: + name: Client identifier. + + Returns: + The registered strands MCP client. + + Raises: + KeyError: If no client with this name is registered. + """ + if name not in self._clients: + raise KeyError(f"MCP client '{name}' not registered.\nAvailable: {list(self._clients)}") + return self._clients[name] + + def start(self) -> None: + """Start all servers and wait for readiness. + + **Idempotent**: if already started, returns immediately. + ``load()`` calls this before creating agents (so MCP clients can + connect), and the context manager calls it again on enter — the + second call is a no-op. The context manager is still needed for + **graceful shutdown** via ``stop()``. + + Clients are **not** started here — strands automatically starts + MCPClient instances when they are registered as tool providers + on an Agent. Starting them here would cause a "session is currently + running" error when the Agent tries to start them again. + + Raises: + RuntimeError: If any server fails to start or become ready. + """ + if self._started: + return + + # Phase 1: Start all servers + for name, server in self._servers.items(): + logger.info("server=<%s> | starting MCP server", name) + server.start() + + # Phase 2: Wait for all servers to be ready + for name, server in self._servers.items(): + if not server.wait_ready(timeout=self._server_ready_timeout): + raise RuntimeError( + f"MCP server '{name}' did not become ready within {self._server_ready_timeout}s" + ) + logger.info("server=<%s> | MCP server is ready", name) + + self._started = True + + def stop(self) -> None: + """Stop all clients first, then all servers. + + Clients that were never started (e.g., never registered on an Agent) + are skipped gracefully. + """ + if not self._started: + return + + # Phase 1: Stop all clients + for name, client in self._clients.items(): + try: + # Normal shutdown — no exception context (matches __exit__ protocol) + client.stop(exc_type=None, exc_val=None, exc_tb=None) + logger.info("client=<%s> | MCP client stopped", name) + except Exception: + logger.warning("client=<%s> | failed to stop MCP client", name, exc_info=True) + + # Phase 2: Stop all servers + for name, server in self._servers.items(): + try: + server.stop() + logger.info("server=<%s> | MCP server stopped", name) + except Exception: + logger.warning("server=<%s> | failed to stop MCP server", name, exc_info=True) + + self._started = False + + def __enter__(self) -> MCPLifecycle: + """Start lifecycle on context entry.""" + self.start() + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Stop lifecycle on context exit.""" + self.stop() + + async def __aenter__(self) -> MCPLifecycle: + """Async context entry — delegates to sync :meth:`start`. + + Useful with Starlette / ASGI lifespan:: + + @asynccontextmanager + async def lifespan(app): + async with lifecycle: + yield + """ + self.start() + return self + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, + ) -> None: + """Async context exit — delegates to sync :meth:`stop`.""" + self.stop() + + @property + def servers(self) -> dict[str, MCPServer]: + """Read-only view of registered servers.""" + return dict(self._servers) + + @property + def clients(self) -> dict[str, StrandsMCPClient]: + """Read-only view of registered clients.""" + return dict(self._clients) diff --git a/src/strands_compose/mcp/server.py b/src/strands_compose/mcp/server.py new file mode 100644 index 0000000..61b0f33 --- /dev/null +++ b/src/strands_compose/mcp/server.py @@ -0,0 +1,327 @@ +"""Abstract MCP server base class. + +Subclasses implement :meth:`_register_tools` to add tools on the +underlying ``FastMCP`` instance. The base handles lifecycle — +start in a background daemon thread, readiness signaling via +``threading.Event``, and graceful shutdown. + +Graceful shutdown +----------------- +The server bypasses ``FastMCP.run()`` — which creates a local +``uvicorn.Server`` that is inaccessible after the call — and instead +obtains the Starlette ASGI app via ``FastMCP.streamable_http_app()`` / +``FastMCP.sse_app()``, then creates and manages its own +``uvicorn.Server``. This gives us access to +``uvicorn.Server.should_exit`` for clean shutdown from any thread. + +Only HTTP transports (``streamable-http``, ``sse``) are supported. +``stdio`` is a client-side transport where the client spawns a server +subprocess — there is no server to manage here. + +Example:: + + class PostgresServer(MCPServer): + def _register_tools(self, mcp: FastMCP) -> None: + mcp.tool()(my_query_func) + + + server = PostgresServer(name="postgres", port=8001) + server.start() + server.wait_ready(timeout=10) + # ... use server ... + server.stop() +""" + +from __future__ import annotations + +import asyncio +import logging +import socket +import threading +import time +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import TYPE_CHECKING, Any + +from .transports import DEFAULT_TRANSPORT, MCP_SERVER_TRANSPORT + +if TYPE_CHECKING: + import uvicorn + from mcp.server.fastmcp import FastMCP + + +logger = logging.getLogger(__name__) + + +class MCPServer(ABC): + """Abstract base for strands_compose MCP servers.""" + + #: Seconds to wait for uvicorn graceful drain after ``should_exit``. + STOP_TIMEOUT: float = 5 + #: Extra seconds to wait after ``force_exit`` before giving up. + STOP_FORCE_TIMEOUT: float = 2 + + def __init__( + self, + *, + name: str, + host: str = "127.0.0.1", + port: int = 8000, + transport: MCP_SERVER_TRANSPORT = DEFAULT_TRANSPORT, + server_params: dict[str, Any] | None = None, + ) -> None: + """Initialize the MCPServer. + + Subclasses implement ``_register_tools()`` to register tools on the + ``FastMCP`` instance. The base class manages background-thread + lifecycle and readiness signaling. + + Args: + name: Unique server identifier. + host: Bind address for the HTTP transport. + port: Bind port for the HTTP transport. + transport: MCP server transport type (``streamable-http`` or ``sse``). + server_params: Extra keyword arguments forwarded to ``FastMCP()``. + """ + self.name = name + self.host = host + self.port = port + self.transport = transport + self.server_params = server_params or {} + self._mcp: FastMCP | None = None + self._thread: threading.Thread | None = None + self._ready = threading.Event() + self._error: BaseException | None = None + self._uvicorn_server: uvicorn.Server | None = None + + # -- properties ------------------------------------------------- # + + @property + def url(self) -> str: + """Base URL of this server (for client transport).""" + return f"http://{self.host}:{self.port}/mcp" + + @property + def is_running(self) -> bool: + """True if the server thread is alive.""" + return self._thread is not None and self._thread.is_alive() + + # -- server creation -------------------------------------------- # + + def create_server(self) -> FastMCP: + """Build the ``FastMCP`` instance and register tools. + + The result is cached — calling twice returns the same instance. + """ + if self._mcp is not None: + return self._mcp + + from mcp.server.fastmcp import FastMCP as _FastMCP + + mcp = _FastMCP( + self.name, + host=self.host, + port=self.port, + stateless_http=True, + json_response=True, + log_level="WARNING", + **self.server_params, + ) + self._register_tools(mcp) + self._mcp = mcp + return mcp + + # -- lifecycle -------------------------------------------------- # + + def _get_asgi_app(self, mcp: FastMCP) -> Any: + """Return the Starlette ASGI app for the current transport. + + Calls the corresponding public method on ``FastMCP`` which lazily + initialises the session manager and returns a ``Starlette`` + instance. + + Raises: + ValueError: If the transport type is not supported. + """ + if self.transport == "streamable-http": + return mcp.streamable_http_app() + if self.transport == "sse": + return mcp.sse_app() + raise ValueError( + f"Unsupported server transport: {self.transport!r}. " + "MCPServer only supports 'streamable-http' and 'sse'. " + "The 'stdio' transport is a client-side transport where the client " + "spawns the server as a subprocess." + ) + + def run(self) -> None: + """Start the server blocking (for standalone CLI usage). + + In the main thread ``FastMCP.run()`` installs signal handlers so + that Ctrl-C triggers a graceful uvicorn shutdown. + """ + mcp = self.create_server() + mcp.run(transport=self.transport) + + def start(self) -> None: + """Start the server in a background daemon thread. + + Creates its own ``uvicorn.Server`` instead of delegating to + ``FastMCP.run()``. This keeps a reference to the server so that + :meth:`stop` can trigger a graceful shutdown via + ``uvicorn.Server.should_exit``. + """ + if self.is_running: + return + self._ready.clear() + self._error = None + + mcp = self.create_server() + asgi_app = self._get_asgi_app(mcp) + + import uvicorn as _uvicorn + + config = _uvicorn.Config( + asgi_app, + host=self.host, + port=self.port, + log_level="warning", + ) + self._uvicorn_server = _uvicorn.Server(config) + + def _target() -> None: + try: + asyncio.run(self._uvicorn_server.serve()) # type: ignore[union-attr] + except BaseException as exc: + self._error = exc + self._ready.set() + + self._thread = threading.Thread( + target=_target, + name=f"mcp-{self.name}", + daemon=True, + ) + self._thread.start() + + def wait_ready(self, timeout: float = 30) -> bool: + """Wait for the server to be ready by polling the TCP port. + + Returns: + True if server is ready, False if timed out. + + Raises: + RuntimeError: If the server thread died before becoming ready. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if self._error is not None: + raise RuntimeError( + f"MCP server '{self.name}' failed to start: {self._error}" + ) from self._error + if self._thread is not None and not self._thread.is_alive(): + raise RuntimeError(f"MCP server '{self.name}' thread exited unexpectedly") + try: + with socket.create_connection((self.host, self.port), timeout=1): + self._ready.set() + return True + except OSError: + time.sleep(0.1) + return False + + def stop(self) -> None: + """Stop the server and clean up the background thread. + + Signals ``uvicorn.Server.should_exit`` which triggers a graceful + drain (stop accepting new connections, finish in-flight requests). + If the thread does not exit within :attr:`STOP_TIMEOUT` seconds, + ``force_exit`` is set to skip connection draining. After a + further :attr:`STOP_FORCE_TIMEOUT` seconds the thread is + abandoned as a daemon thread and will be reaped when the process + exits. + """ + if self._thread is not None and self._thread.is_alive(): + if self._uvicorn_server is not None: + # Graceful phase: ask uvicorn to stop accepting and drain. + self._uvicorn_server.should_exit = True + self._thread.join(timeout=self.STOP_TIMEOUT) + + if self._thread.is_alive(): + # Forceful phase: skip connection draining. + logger.info( + "server=<%s>, timeout=<%s> | forcing exit after graceful stop timeout", + self.name, + self.STOP_TIMEOUT, + ) + self._uvicorn_server.force_exit = True + self._thread.join(timeout=self.STOP_FORCE_TIMEOUT) + + if self._thread.is_alive(): + logger.warning( + "server=<%s> | thread did not stop, daemon will be reaped at exit", self.name + ) + + self._uvicorn_server = None + self._mcp = None + self._thread = None + self._ready.clear() + + # -- extension point -------------------------------------------- # + + @abstractmethod + def _register_tools(self, mcp: FastMCP) -> None: + """Register tools, routes, and resources on the FastMCP instance.""" + ... + + +def create_mcp_server( + *, + name: str, + tools: list[Callable[..., Any]], + host: str = "127.0.0.1", + port: int = 8000, + transport: MCP_SERVER_TRANSPORT = DEFAULT_TRANSPORT, + server_params: dict[str, Any] | None = None, +) -> MCPServer: + """Create an MCP server from a list of callables — no subclassing needed. + + Each callable (sync or async) is registered as a tool on the underlying + ``FastMCP`` instance. For advanced use (custom state, routes, resources), + subclass :class:`MCPServer` directly. + + Example:: + + def get_weather(city: str) -> str: + return f"Sunny in {city}" + + + async def query_db(sql: str) -> str: ... + + + server = create_mcp_server(name="weather", tools=[get_weather, query_db], port=8001) + server.start() + + Args: + name: Unique server identifier. + tools: Callables to register as MCP tools. + host: Bind address (default ``127.0.0.1``). + port: Bind port (default ``8000``). + transport: Server transport type (``streamable-http`` or ``sse``). + server_params: Extra kwargs forwarded to ``FastMCP()``. + + Returns: + A ready-to-use :class:`MCPServer` instance. + """ + tool_fns = list(tools) + + class _FactoryServer(MCPServer): + def _register_tools(self, mcp: FastMCP) -> None: + for fn in tool_fns: + mcp.tool()(fn) + + return _FactoryServer( + name=name, + host=host, + port=port, + transport=transport, + server_params=server_params, + ) diff --git a/src/strands_compose/mcp/transports.py b/src/strands_compose/mcp/transports.py new file mode 100644 index 0000000..f1780b6 --- /dev/null +++ b/src/strands_compose/mcp/transports.py @@ -0,0 +1,188 @@ +"""MCP transport factory functions for strands MCPClient. + +Each factory returns a callable that produces an MCPTransport (async context manager +yielding (read_stream, write_stream)). Pass the result to strands MCPClient: + + from strands.tools.mcp import MCPClient + transport = streamable_http_transport("http://localhost:8000/mcp") + client = MCPClient(transport_callable=transport) +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Any, Literal + +if TYPE_CHECKING: + from collections.abc import Callable + +logger = logging.getLogger(__name__) + +MCP_TRANSPORT = Literal["stdio", "sse", "streamable-http"] +"""All MCP transport types (client and server).""" + +MCP_SERVER_TRANSPORT = Literal["sse", "streamable-http"] +"""Transport types valid for :class:`~strands_compose.mcp.server.MCPServer`. + +``stdio`` is excluded because it is a client-side transport where the +client spawns the server as a subprocess and communicates over +stdin/stdout pipes — there is no HTTP server to manage. +""" + +DEFAULT_TRANSPORT: MCP_SERVER_TRANSPORT = "streamable-http" + + +def stdio_transport( + command: list[str], + env: dict[str, str] | None = None, + *, + cwd: str | Path | None = None, + encoding: str = "utf-8", + encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict", +) -> Callable[[], Any]: + """Create a stdio transport callable for a subprocess MCP server. + + Args: + command: Command to start the MCP server (e.g., ["python", "-m", "myserver"]). + env: Optional environment variables for the subprocess. + cwd: Working directory for the subprocess. + encoding: Text encoding for messages (default: utf-8). + encoding_error_handler: How to handle encoding errors (default: strict). + + Returns: + Transport callable for strands MCPClient. + + Raises: + ValueError: If command is empty. + """ + if not command: + raise ValueError("command must be a non-empty list (e.g., ['python', '-m', 'myserver'])") + + captured_command = list(command) + captured_env = dict(env) if env is not None else None + captured_cwd = cwd + captured_encoding = encoding + captured_encoding_error_handler = encoding_error_handler + + def factory() -> Any: + from mcp.client.stdio import StdioServerParameters, stdio_client + + params = StdioServerParameters( + command=captured_command[0], + args=captured_command[1:], + env=captured_env, + cwd=captured_cwd, + encoding=captured_encoding, + encoding_error_handler=captured_encoding_error_handler, + ) + return stdio_client(params) + + return factory + + +def sse_transport( + url: str, + headers: dict[str, Any] | None = None, + *, + timeout: float = 5, + sse_read_timeout: float = 300, + auth: Any | None = None, + httpx_client_factory: Any | None = None, +) -> Callable[[], Any]: + """Create an SSE (Server-Sent Events) transport callable. + + Args: + url: SSE endpoint URL. + headers: Optional HTTP headers. + timeout: HTTP timeout in seconds (default: 5). + sse_read_timeout: Timeout waiting for SSE events in seconds (default: 300). + auth: Optional httpx.Auth instance (e.g., OAuth provider). + httpx_client_factory: Optional factory for creating httpx client. + + Returns: + Transport callable for strands MCPClient. + + Raises: + ValueError: If url is empty. + """ + if not url: + raise ValueError("url must be a non-empty string") + + captured_headers = headers or {} + captured_timeout = timeout + captured_sse_read_timeout = sse_read_timeout + captured_auth = auth + captured_httpx_client_factory = httpx_client_factory + + def factory() -> Any: + from mcp.client.sse import sse_client + + kwargs: dict[str, Any] = { + "url": url, + "headers": captured_headers, + "timeout": captured_timeout, + "sse_read_timeout": captured_sse_read_timeout, + } + if captured_auth is not None: + kwargs["auth"] = captured_auth + if captured_httpx_client_factory is not None: + kwargs["httpx_client_factory"] = captured_httpx_client_factory + return sse_client(**kwargs) + + return factory + + +def streamable_http_transport( + url: str, + headers: dict[str, str] | None = None, + *, + http_client: Any | None = None, + terminate_on_close: bool = True, +) -> Callable[[], Any]: + """Create a streamable HTTP transport callable. + + For full control (auth, timeouts, custom TLS, etc.), pass a pre-configured + ``httpx.AsyncClient`` via ``http_client``. When ``http_client`` is provided, + ``headers`` is ignored (configure headers on the client directly). + + Args: + url: HTTP endpoint URL (e.g., "http://localhost:8000/mcp"). + headers: Optional HTTP headers. Ignored when ``http_client`` is provided. + http_client: Optional pre-configured ``httpx.AsyncClient``. + terminate_on_close: Send DELETE to close session (default: True). + + Returns: + Transport callable for strands MCPClient. + + Raises: + ValueError: If url is empty. + """ + if not url: + raise ValueError("url must be a non-empty string") + + captured_headers = dict(headers) if headers else None + captured_http_client = http_client + captured_terminate_on_close = terminate_on_close + + def factory() -> Any: + from mcp.client.streamable_http import streamable_http_client + + if captured_http_client is not None: + return streamable_http_client( + url=url, + http_client=captured_http_client, + terminate_on_close=captured_terminate_on_close, + ) + if captured_headers: + import httpx + + client = httpx.AsyncClient(headers=captured_headers) + return streamable_http_client( + url=url, + http_client=client, + terminate_on_close=captured_terminate_on_close, + ) + return streamable_http_client(url=url, terminate_on_close=captured_terminate_on_close) + + return factory diff --git a/src/strands_compose/models.py b/src/strands_compose/models.py new file mode 100644 index 0000000..c49d42a --- /dev/null +++ b/src/strands_compose/models.py @@ -0,0 +1,69 @@ +"""LLM model factory.""" + +from __future__ import annotations + +from typing import Any + +from strands.models import Model + +PROVIDERS = ("bedrock", "ollama", "openai", "gemini") + + +def create_model(provider: str, model_id: str, **params: Any) -> Model: + """Dispatch to the appropriate model factory by provider name. + + Args: + provider: ``"ollama"`` or ``"bedrock"`` or ``"openai"`` or ``"gemini"``. + model_id: Model identifier. + **params: Provider-specific keyword arguments. + + Returns: + Strands model instance. + + Raises: + ValueError: If the provider is unknown. + ImportError: If a required optional provider package is not installed. + """ + match provider.lower(): + case "bedrock": + from strands.models.bedrock import BedrockModel + + return BedrockModel(model_id=model_id, **params) + + case "ollama": + try: + from strands.models.ollama import OllamaModel + except ImportError: + raise ImportError( + "The 'ollama' provider requires the ollama extra:\n" + " pip install strands-compose[ollama]\n" + "Or install directly: pip install strands-agents[ollama]" + ) from None + return OllamaModel(model_id=model_id, **params) + + case "openai": + try: + from strands.models.openai import OpenAIModel + except ImportError: + raise ImportError( + "The 'openai' provider requires the openai extra:\n" + " pip install strands-compose[openai]\n" + "Or install directly: pip install strands-agents[openai]" + ) from None + return OpenAIModel(model_id=model_id, **params) + + case "gemini": + try: + from strands.models.gemini import GeminiModel + except ImportError: + raise ImportError( + "The 'gemini' provider requires the gemini extra:\n" + " pip install strands-compose[gemini]\n" + "Or install directly: pip install strands-agents[gemini]" + ) from None + return GeminiModel(model_id=model_id, **params) + + case _: + raise ValueError( + f"Unknown model provider '{provider}'.\nAvailable: {', '.join(PROVIDERS)}." + ) diff --git a/src/strands_compose/renderers/__init__.py b/src/strands_compose/renderers/__init__.py new file mode 100644 index 0000000..04a44fd --- /dev/null +++ b/src/strands_compose/renderers/__init__.py @@ -0,0 +1,9 @@ +"""Renderers for displaying agent output.""" + +from __future__ import annotations + +from .ansi import AnsiRenderer + +__all__ = [ + "AnsiRenderer", +] diff --git a/src/strands_compose/renderers/ansi.py b/src/strands_compose/renderers/ansi.py new file mode 100644 index 0000000..fecfd86 --- /dev/null +++ b/src/strands_compose/renderers/ansi.py @@ -0,0 +1,237 @@ +"""Zero-dependency ANSI renderer for :class:`~strands_compose.wire.StreamEvent` objects. + +Colour codes are automatically suppressed when stdout is not a TTY +(piped / redirected output). + +Usage:: + + from strands_compose import AnsiRenderer + + renderer = AnsiRenderer() + while (event := await queue.get()) is not None: + renderer.render(event) + renderer.flush() + +Key Features: + - Automatic TTY detection with color suppression for piped output + - Inline token and reasoning streaming with mode-change separators + - Full event type coverage including multi-agent orchestration events +""" + +from __future__ import annotations + +import shutil +import sys +from collections.abc import Callable +from typing import Any + +from ..types import EventType +from ..wire import StreamEvent +from .base import EventRenderer + + +class AnsiRenderer(EventRenderer): + """Renders events using raw ANSI escape codes.""" + + def __init__( + self, + *, + file: Any | None = None, + separator_width: int | None = None, + ) -> None: + """Initialize the AnsiRenderer. + + No third-party dependencies. Colour codes are automatically + suppressed when stdout is not a TTY (piped / redirected output). + + Token and reasoning events are written inline (no trailing newline) + so they appear as a continuous stream. A separator line is printed + when the mode changes between reasoning and responding, or when the + active agent changes. + + Args: + file: Output stream (defaults to ``sys.stdout``). + separator_width: Width of separator lines. Defaults to 70 or the + current terminal width, whichever is available. + """ + self._out = file or sys.stdout + self._in_stream = False + self._mode: str | None = None # "reasoning" | "responding" | None + self._active_agent: str | None = None + + # Cache TTY state and pre-compute ANSI escape strings. + is_tty = hasattr(self._out, "isatty") and self._out.isatty() + self._dim = "\033[2m" if is_tty else "" + self._bold = "\033[1m" if is_tty else "" + self._cyan = "\033[36m" if is_tty else "" + self._green = "\033[32m" if is_tty else "" + self._red = "\033[31m" if is_tty else "" + self._yellow = "\033[33m" if is_tty else "" + self._reset = "\033[0m" if is_tty else "" + + if separator_width is not None: + self._separator_width = separator_width + else: + self._separator_width = shutil.get_terminal_size((70, 24)).columns + + self._handlers: dict[str, Callable[[StreamEvent], None]] = { + EventType.TOKEN: self._handle_token, + EventType.AGENT_START: self._handle_agent_start, + EventType.TOOL_START: self._handle_tool_start, + EventType.TOOL_END: self._handle_tool_end, + EventType.COMPLETE: self._handle_complete, + EventType.ERROR: self._handle_error, + EventType.NODE_START: self._handle_node_start, + EventType.NODE_STOP: self._handle_node_stop, + EventType.HANDOFF: self._handle_handoff, + EventType.MULTIAGENT_START: self._handle_multiagent_start, + EventType.MULTIAGENT_COMPLETE: self._handle_multiagent_complete, + EventType.REASONING: self._handle_reasoning, + } + + # -- Public API -------------------------------------------------------- + + def render(self, event: StreamEvent) -> None: # noqa: D102 + handler = self._handlers.get(event.type) + if handler is not None: + handler(event) + + def flush(self) -> None: # noqa: D102 + if self._in_stream: + self._out.write("\n") + self._out.flush() + self._in_stream = False + + # -- Per-event-type handlers ------------------------------------------- + + def _handle_token(self, event: StreamEvent) -> None: + self._ensure_mode(event.agent_name, "responding") + self._out.write(event.data.get("text", "")) + self._out.flush() + self._in_stream = True + + def _handle_reasoning(self, event: StreamEvent) -> None: + self._ensure_mode(event.agent_name, "reasoning") + self._out.write(f"{self._yellow}{event.data.get('text', '')}{self._reset}") + self._out.flush() + self._in_stream = True + + def _handle_agent_start(self, event: StreamEvent) -> None: + self._break() + self._mode = None + self._active_agent = None + self._out.write(f"\n{self._cyan}{self._bold}[{event.agent_name}]{self._reset} starting…\n") + self._out.flush() + + def _handle_tool_start(self, event: StreamEvent) -> None: + self._break() + self._mode = None + data = event.data + label = data.get("tool_label") or data.get("tool_name", "unknown") + tool_input = data.get("tool_input", {}) + preview = str(tool_input)[:80] + ("…" if len(str(tool_input)) > 80 else "") + self._out.write( + f" {self._yellow}⚙{self._reset} [{event.agent_name}] -> {label!r}" + f" {self._dim}{preview}{self._reset}\n" + ) + self._out.flush() + + def _handle_tool_end(self, event: StreamEvent) -> None: + self._break() + self._mode = None + status = event.data.get("status", "?") + agent = event.agent_name + if status == "error": + self._out.write( + f" {self._red}✗{self._reset} [{agent}] tool error: {event.data.get('error')}\n" + ) + else: + self._out.write(f" {self._green}✓{self._reset} [{agent}] tool done\n") + self._out.flush() + + def _handle_complete(self, event: StreamEvent) -> None: + self._break() + self._mode = None + self._active_agent = None + usage = event.data.get("usage", {}) + in_tokens = usage.get("input_tokens", 0) + out_tokens = usage.get("output_tokens", 0) + self._out.write( + f" {self._dim}✅ [{event.agent_name}] complete ({in_tokens} input, {out_tokens} output tokens){self._reset}\n" + ) + self._out.flush() + + def _handle_error(self, event: StreamEvent) -> None: + self._break() + self._mode = None + msg = event.data.get("message", "unknown error") + exc_type = event.data.get("exception_type") + if exc_type and msg.startswith(f"{exc_type}: "): + detail = msg[len(exc_type) + 2 :] + self._out.write( + f" {self._red}✗ [{event.agent_name}] ERROR: {exc_type}:\n" + f" {detail}{self._reset}\n" + ) + else: + self._out.write(f" {self._red}✗ [{event.agent_name}] ERROR: {msg}{self._reset}\n") + self._out.flush() + + def _handle_node_start(self, event: StreamEvent) -> None: + self._break() + self._out.write( + f"\n{self._cyan}->{self._reset} node '{event.data.get('node_id')}' starting\n" + ) + self._out.flush() + + def _handle_node_stop(self, event: StreamEvent) -> None: + self._break() + self._out.write(f"{self._cyan}←{self._reset} node '{event.data.get('node_id')}' done\n") + self._out.flush() + + def _handle_handoff(self, event: StreamEvent) -> None: + self._break() + to_ids = ", ".join(event.data.get("to_node_ids", [])) + self._out.write(f" {self._cyan}↪{self._reset} handoff -> {to_ids}\n") + self._out.flush() + + def _handle_multiagent_start(self, event: StreamEvent) -> None: + self._break() + kind = event.data.get("multiagent_type", "") + self._out.write(f"\n{self._cyan}⊕{self._reset} {kind} orchestration starting\n") + self._out.flush() + + def _handle_multiagent_complete(self, event: StreamEvent) -> None: + self._break() + kind = event.data.get("multiagent_type", "") + self._out.write(f"{self._cyan}⊗{self._reset} {kind} orchestration complete\n\n") + self._out.flush() + + # -- Internal helpers -------------------------------------------------- + + def _break(self) -> None: + """Insert a newline if we are mid-token/reasoning stream.""" + if self._in_stream: + self._out.write("\n") + self._out.flush() + self._in_stream = False + + def _separator(self, agent: str, label: str, color: str | None = None) -> str: + """Build a separator line like ``── agent — REASONING ──``.""" + c = color if color is not None else self._dim + inner = f" {agent} \u2014 {label} " + pad = max(0, self._separator_width - len(inner) - 4) + left = 2 + right = pad - left if pad > left else 0 + return f"\n{c}{'─' * left}{inner}{'─' * right}{self._reset}\n" + + def _ensure_mode(self, agent: str, mode: str) -> None: + """Print a separator when the agent or mode changes.""" + if mode == self._mode and agent == self._active_agent: + return + self._break() + self._active_agent = agent + self._mode = mode + label = "REASONING" if mode == "reasoning" else "RESPONDING" + color = self._yellow if mode == "reasoning" else self._cyan + self._out.write(self._separator(agent, label, color=color)) + self._out.flush() diff --git a/src/strands_compose/renderers/base.py b/src/strands_compose/renderers/base.py new file mode 100644 index 0000000..77bd960 --- /dev/null +++ b/src/strands_compose/renderers/base.py @@ -0,0 +1,36 @@ +"""Abstract base class for event renderers.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from ..wire import StreamEvent + + +class EventRenderer(ABC): + """Renders :class:`StreamEvent` objects to a terminal. + + Subclasses implement :meth:`render` to handle one event at a time. + The renderer is **stateful** — it tracks inline token streaming so + structured events can break the line cleanly. + + Create a new instance per prompt / conversation turn. + """ + + @abstractmethod + def render(self, event: StreamEvent) -> None: + """Render a single event to the terminal. + + Args: + event: The event to render. + """ + ... + + @abstractmethod + def flush(self) -> None: + """Flush any pending output. + + Call after the event stream ends to ensure a trailing token + stream is newline-terminated. + """ + ... diff --git a/src/strands_compose/startup/__init__.py b/src/strands_compose/startup/__init__.py new file mode 100644 index 0000000..bb36d9d --- /dev/null +++ b/src/strands_compose/startup/__init__.py @@ -0,0 +1,24 @@ +"""Pre-flight startup validation and health checking. + +Usage:: + + from strands_compose.startup import validate_mcp, StartupReport + + report = await validate_mcp(infra) # or validate_mcp(resolved_config) + report.print_summary() + report.raise_if_critical() +""" + +from __future__ import annotations + +from .report import CheckResult, Severity, StartupError, StartupReport +from .validator import probe_http_health, validate_mcp + +__all__ = [ + "CheckResult", + "Severity", + "StartupError", + "StartupReport", + "probe_http_health", + "validate_mcp", +] diff --git a/src/strands_compose/startup/report.py b/src/strands_compose/startup/report.py new file mode 100644 index 0000000..af9596e --- /dev/null +++ b/src/strands_compose/startup/report.py @@ -0,0 +1,174 @@ +"""Startup health-check result types. + +Provides :class:`CheckResult` for individual check outcomes, +:class:`StartupReport` for aggregated results, and +:class:`StartupError` raised when critical checks fail. +""" + +from __future__ import annotations + +import dataclasses +import logging +from typing import Literal + +logger = logging.getLogger(__name__) + +Severity = Literal["critical", "warning", "info"] + + +@dataclasses.dataclass +class CheckResult: + """Result of a single startup validation check. + + Attributes: + ok: ``True`` if the check passed. + category: Validation category — ``"network"``, ``"config"``, or ``"runtime"``. + subject: Identifier for what was checked (e.g. ``"model:bedrock"``, ``"mcp:postgres"``). + message: One-line human-readable description of what was found. + severity: Impact level — ``"critical"`` blocks startup, + ``"warning"`` allows startup with degraded functionality, + ``"info"`` is informational. + hint: Actionable fix suggestion when the check fails. + exception: Original exception that caused a failure. + """ + + ok: bool + category: str + subject: str + message: str + severity: Severity = "info" + hint: str = "" + exception: Exception | None = dataclasses.field(default=None, repr=False) + + def __str__(self) -> str: + """Format the check result as a human-readable string.""" + icon = "\u2713" if self.ok else ("\u26a0" if self.severity == "warning" else "\u2717") + parts = [f"[{self.category:8s}] {icon} {self.subject}: {self.message}"] + if not self.ok and self.hint: + parts.append(f" hint: {self.hint}") + return "\n".join(parts) + + @classmethod + def passed(cls, category: str, subject: str, message: str) -> CheckResult: + """Create a passing ``info`` result.""" + return cls(ok=True, category=category, subject=subject, message=message) + + @classmethod + def warn( + cls, + category: str, + subject: str, + message: str, + *, + hint: str = "", + exception: Exception | None = None, + ) -> CheckResult: + """Create a non-critical ``warning`` result.""" + return cls( + ok=False, + category=category, + subject=subject, + message=message, + severity="warning", + hint=hint, + exception=exception, + ) + + @classmethod + def critical( + cls, + category: str, + subject: str, + message: str, + *, + hint: str = "", + exception: Exception | None = None, + ) -> CheckResult: + """Create a ``critical`` failure result.""" + return cls( + ok=False, + category=category, + subject=subject, + message=message, + severity="critical", + hint=hint, + exception=exception, + ) + + +class StartupError(Exception): + """Raised when critical startup checks fail.""" + + def __init__(self, report: StartupReport) -> None: + """Initialize StartupError with the failing report. + + Args: + report: The startup report containing critical failures. + """ + self.report = report + messages = [f" - {c.subject}: {c.message}" for c in report.critical_checks] + super().__init__("Startup failed:\n" + "\n".join(messages)) + + +@dataclasses.dataclass +class StartupReport: + """Aggregated startup validation results.""" + + checks: list[CheckResult] = dataclasses.field(default_factory=list) + + @property + def ok(self) -> bool: + """``True`` if no critical checks failed.""" + return not any(c.severity == "critical" for c in self.checks) + + @property + def warnings(self) -> list[CheckResult]: + """Checks with ``severity="warning"``.""" + return [c for c in self.checks if c.severity == "warning"] + + @property + def critical_checks(self) -> list[CheckResult]: + """Checks with ``severity="critical"``.""" + return [c for c in self.checks if c.severity == "critical"] + + @property + def passed_checks(self) -> list[CheckResult]: + """Checks that passed (``ok=True``).""" + return [c for c in self.checks if c.ok] + + def raise_if_critical(self) -> None: + """Raise :exc:`StartupError` if any critical checks failed. + + Raises: + StartupError: With a summary of all critical failures. + """ + if not self.ok: + raise StartupError(self) + + def print_summary(self, *, verbose: bool = False) -> None: + """Print a human-readable summary to the log. + + Args: + verbose: If ``True``, also print passing checks. + """ + for check in self.checks: + if verbose or not check.ok: + logger.info("%s", check) + + n_ok = len(self.passed_checks) + n_warn = len(self.warnings) + n_crit = len(self.critical_checks) + total = len(self.checks) + status = "OK" if self.ok else "FAILED" + suffix = "" + if n_warn: + suffix += f", {n_warn} warning(s)" + if n_crit: + suffix += f", {n_crit} critical" + logger.info( + "status=<%s>, passed=<%d>, total=<%d> | startup check summary%s", + status, + n_ok, + total, + suffix, + ) diff --git a/src/strands_compose/startup/validator.py b/src/strands_compose/startup/validator.py new file mode 100644 index 0000000..4ae4655 --- /dev/null +++ b/src/strands_compose/startup/validator.py @@ -0,0 +1,167 @@ +"""Startup validation checks for MCP servers, clients, and model endpoints. + +Runs health probes AFTER config resolution to catch connectivity issues +before the user starts chatting. + +**This module is opt-in** — ``validate_mcp()`` is NOT called automatically by +``load()`` or the serve pipeline. Call it explicitly after +``mcp_lifecycle.start()`` when MCP clients are connected:: + + infra = resolve_infra(app_config) + infra.mcp_lifecycle.start() + report = await validate_mcp(infra) # no agent build needed + report.print_summary() +""" + +from __future__ import annotations + +import asyncio +import logging +import urllib.error +import urllib.request +from typing import TYPE_CHECKING + +from .report import CheckResult, StartupReport + +if TYPE_CHECKING: + from strands.tools.mcp import MCPClient as StrandsMCPClient + + from ..config.resolvers import ResolvedConfig, ResolvedInfra + from ..mcp.server import MCPServer + +logger = logging.getLogger(__name__) + + +async def validate_mcp(target: ResolvedConfig | ResolvedInfra) -> StartupReport: + """Run all startup validation checks. + + Checks: + 1. MCP servers are reachable (HTTP probe). + 2. MCP clients have active sessions. + + Accepts either a :class:`ResolvedConfig` or :class:`ResolvedInfra` — + only the ``mcp_lifecycle`` attribute is used, so agents are **not** + required. This avoids an unnecessary cold-start agent build when + validating during ASGI lifespan startup. + + Args: + target: A resolved config or infrastructure object with + ``mcp_lifecycle``. + + Returns: + StartupReport with all check results. + """ + checks: list[CheckResult] = [] + + server_tasks = [ + _check_mcp_server(name, server) for name, server in target.mcp_lifecycle.servers.items() + ] + client_tasks = [ + _check_mcp_client(name, client) for name, client in target.mcp_lifecycle.clients.items() + ] + + # Run server probes concurrently, then gather client checks (which are fast) + server_results = await asyncio.gather(*server_tasks, return_exceptions=True) + for result in server_results: + if isinstance(result, BaseException): + checks.append( + CheckResult.critical( + "network", + "mcp-server", + f"Unexpected error: {result}", + exception=result if isinstance(result, Exception) else None, + ) + ) + else: + checks.extend(result) + + # The same for clients checks + client_results = await asyncio.gather(*client_tasks, return_exceptions=True) + for result in client_results: + if isinstance(result, BaseException): + checks.append( + CheckResult.warn( + "runtime", + "mcp-client", + f"Unexpected error: {result}", + hint="Client checks failed, but servers may still be healthy", + exception=result if isinstance(result, Exception) else None, + ) + ) + else: + checks.append(result) + + return StartupReport(checks=checks) + + +async def _check_mcp_server(name: str, server: MCPServer) -> list[CheckResult]: + """Probe an MCP server's HTTP endpoint.""" + subject = f"mcp:{name}" + return [await probe_http_health(subject, server.url)] + + +async def _check_mcp_client(name: str, client: StrandsMCPClient) -> CheckResult: + """Check if an MCP client's session is active.""" + subject = f"mcp-client:{name}" + try: + tools = await client.load_tools() + if tools is not None: + return CheckResult.passed("runtime", subject, "Client has tool registry") + return CheckResult.passed("runtime", subject, "Client is available") + except Exception as exc: + return CheckResult.warn( + "runtime", + subject, + f"Client check failed: {exc}", + hint=f"Ensure the MCP server for client '{name}' is running", + exception=exc, + ) + + +async def probe_http_health(subject: str, url: str) -> CheckResult: + """Probe an HTTP endpoint for reachability. + + Any HTTP response (including 4xx — e.g. 406 from an MCP endpoint that + only accepts POST) is treated as *reachable*. Only 5xx responses and + connection-level failures (timeout, refused) are reported as problems. + + Args: + subject: Human-readable subject name. + url: URL to probe. + + Returns: + CheckResult with pass/fail. + """ + try: + resp = await asyncio.to_thread( + urllib.request.urlopen, + url, + timeout=5, # noqa: S310 + ) + status = resp.status + if status < 500: + return CheckResult.passed("network", subject, f"HTTP {status}") + return CheckResult.warn( + "network", + subject, + f"HTTP {status}", + hint=f"Service at {url} returned a server error", + ) + except urllib.error.HTTPError as exc: + # HTTPError is raised for 4xx/5xx but the server *is* reachable. + if exc.code < 500: + return CheckResult.passed("network", subject, f"HTTP {exc.code}") + return CheckResult.warn( + "network", + subject, + f"HTTP {exc.code}", + hint=f"Service at {url} returned a server error", + ) + except Exception as exc: + return CheckResult.critical( + "network", + subject, + f"Connection failed: {exc}", + hint=f"Ensure the service is running at {url}", + exception=exc, + ) diff --git a/src/strands_compose/tools/__init__.py b/src/strands_compose/tools/__init__.py new file mode 100644 index 0000000..da3d51e --- /dev/null +++ b/src/strands_compose/tools/__init__.py @@ -0,0 +1,33 @@ +"""Tool loading and wrapping utilities. + +Provides helpers for: +- Loading ``@tool``-decorated functions from files, modules, and directories. +- Wrapping ``Agent`` / ``MultiAgentBase`` nodes as ``AgentTool`` instances + (``node_as_tool``, ``node_as_async_tool``) for delegation. +""" + +from __future__ import annotations + +from .loaders import ( + load_tool_function, + load_tools_from_directory, + load_tools_from_file, + load_tools_from_module, + resolve_tool_spec, + resolve_tool_specs, +) +from .wrappers import ( + node_as_async_tool, + node_as_tool, +) + +__all__ = [ + "load_tool_function", + "load_tools_from_directory", + "load_tools_from_file", + "load_tools_from_module", + "node_as_async_tool", + "node_as_tool", + "resolve_tool_spec", + "resolve_tool_specs", +] diff --git a/src/strands_compose/tools/extractors.py b/src/strands_compose/tools/extractors.py new file mode 100644 index 0000000..5ae2009 --- /dev/null +++ b/src/strands_compose/tools/extractors.py @@ -0,0 +1,182 @@ +"""Text extraction utilities for agent and multi-agent results. + +Key Features: + - Extract the last text block from strands Agent and MultiAgent results + - Support for SwarmResult and GraphResult node resolution + - Recursive extraction through nested orchestration results +""" + +from __future__ import annotations + +import logging +from typing import Any + +from strands.agent.agent_result import AgentResult +from strands.multiagent.base import MultiAgentResult, NodeResult +from strands.types.content import Message + +logger = logging.getLogger(__name__) + + +def extract_text_from_message(message: Message | None) -> str | None: + """Extract the last text block from a message dict. + + Strands ``ContentBlock`` uses ``{"text": "..."}`` for text blocks (no + ``"type"`` wrapper). This helper collects all content blocks that + contain a ``"text"`` key and returns the last one — the model's final + turn can interleave text and tool-use blocks, and only the last text + block carries the actual answer we want to bubble up. + + Args: + message: A strands ``Message`` dict (e.g. ``AgentResult.message``). + + Returns: + The last text string, or ``None`` if no text blocks exist. + """ + if not message: + return None + content = message.get("content", []) + text_blocks = [ + block["text"] for block in content if isinstance(block, dict) and "text" in block + ] + if text_blocks: + return text_blocks[-1] + return None + + +def extract_text_from_agent_result(result: AgentResult) -> str: + """Extract the final text answer from an ``AgentResult``. + + Tries the last text block in ``result.message``. Falls back to + ``str(result)`` which handles structured output and interrupts. + + Args: + result: An ``AgentResult`` from a single agent invocation. + + Returns: + The extracted answer text. + """ + text = extract_text_from_message(result.message) + if text is not None: + return text + return str(result) + + +def extract_text_from_multi_agent_result(result: MultiAgentResult) -> str: + """Extract the final answer text from a ``MultiAgentResult``. + + ``MultiAgentResult`` (and subclasses ``SwarmResult``, ``GraphResult``) + store per-node results in ``result.results``. This helper locates the + *last* agent that ran — using ``node_history`` for swarms and + ``execution_order`` for graphs — and extracts its text. Falls back to + iterating all node results in reverse order. + + Args: + result: A ``MultiAgentResult`` (or ``SwarmResult`` / ``GraphResult``). + + Returns: + The extracted answer text, or a descriptive fallback string. + """ + # Determine the last agent that executed + last_node_id = resolve_last_node_id(result) + + if last_node_id and last_node_id in result.results: + text = extract_text_from_node_result(result.results[last_node_id]) + if text is not None: + return text + + # Fallback: scan all node results in reverse + for node_result in reversed(list(result.results.values())): + text = extract_text_from_node_result(node_result) + if text is not None: + return text + + logger.warning("status=<%s> | no text extracted from MultiAgentResult", result.status) + return ( + f"[orchestration completed with status {result.status.value} but produced no text output]" + ) + + +def resolve_last_node_id(result: MultiAgentResult) -> str | None: + """Determine the id of the last node that executed in a multi-agent result. + + Args: + result: A ``MultiAgentResult`` (or subclass). + + Returns: + The node id string, or ``None`` if it cannot be determined. + """ + # SwarmResult.node_history — list[SwarmNode], each has .node_id + node_history: list[Any] | None = getattr(result, "node_history", None) + if node_history: + return str(node_history[-1].node_id) + + # GraphResult.execution_order — list[GraphNode], each has .node_id + execution_order: list[Any] | None = getattr(result, "execution_order", None) + if execution_order: + return str(execution_order[-1].node_id) + + return None + + +def extract_text_from_node_result(node_result: NodeResult) -> str | None: + """Extract text from a single ``NodeResult``. + + Handles all three payload shapes: + - ``AgentResult`` — extracts text from ``.message``. + - Nested ``MultiAgentResult`` — recurses into it. + - ``Exception`` — returns the error message. + + Args: + node_result: A ``NodeResult`` from a multi-agent execution. + + Returns: + Extracted text, or ``None`` if no usable text is found. + """ + inner = node_result.result + + if isinstance(inner, AgentResult): + text = extract_text_from_message(inner.message) + if text is not None: + return text + fallback = str(inner) + if fallback.strip(): + return fallback + return None + + if isinstance(inner, MultiAgentResult): + return extract_text_from_multi_agent_result(inner) + + if isinstance(inner, Exception): + logger.warning("error=<%s> | nested node produced an exception", inner) + return f"[nested agent error: {inner}]" + + return None + + +def extract_last_text_block(result: Any) -> str: + """Extract the final text answer from an agent or orchestration result. + + Dispatches to the appropriate extractor based on the result type: + - ``AgentResult`` — extracts the last text block from the message. + - ``MultiAgentResult`` (``SwarmResult``, ``GraphResult``) — drills into + the last executing node's ``AgentResult``. + - Unknown types — falls back to ``str(result)``. + + Args: + result: An ``AgentResult``, ``MultiAgentResult``, or any object. + + Returns: + The extracted answer text. + """ + if isinstance(result, AgentResult): + return extract_text_from_agent_result(result) + + if isinstance(result, MultiAgentResult): + return extract_text_from_multi_agent_result(result) + + # Unknown result type — best-effort fallback. + logger.warning( + "type=<%s> | unexpected result type in extract_last_text_block", type(result).__name__ + ) + return str(result) diff --git a/src/strands_compose/tools/loaders.py b/src/strands_compose/tools/loaders.py new file mode 100644 index 0000000..9d65132 --- /dev/null +++ b/src/strands_compose/tools/loaders.py @@ -0,0 +1,238 @@ +"""Tool loading utilities. + +Provides helpers for loading ``@tool``-decorated functions from files, modules, +and directories. + +Key Features: + - Auto-detection of filesystem vs. module-based tool specs + - Automatic @tool wrapping for explicit colon-spec lookups + - Directory scanning with underscore-prefixed file exclusion + - Unified spec resolver supporting files, modules, and directories +""" + +from __future__ import annotations + +import importlib +import logging +from pathlib import Path +from typing import Any + +from strands.tools.decorator import tool +from strands.types.tools import AgentTool + +from ..utils import load_module_from_file + +logger = logging.getLogger(__name__) + + +def _collect_tools(module: Any) -> list[AgentTool]: + """Scan a module and return all ``@tool``-decorated objects. + + Only ``AgentTool`` instances (i.e. functions decorated with ``@tool`` + from strands) are collected. Plain functions without the decorator are + silently ignored — users must decorate them explicitly. + + Args: + module: An imported Python module. + + Returns: + List of AgentTool instances found in the module. + """ + collected: list[AgentTool] = [] + + for name in dir(module): + if name.startswith("_"): + continue + obj = getattr(module, name) + if isinstance(obj, AgentTool): + collected.append(obj) + + return collected + + +def _ensure_tool(obj: Any, name: str) -> AgentTool: + """Return *obj* as an ``AgentTool``, auto-wrapping with ``@tool`` if needed. + + Called for explicit colon-spec lookups (``file.py:func`` / ``module:func``) + where the user has already named the exact function they want. Wrapping is + safe because the intent is unambiguous. A warning is emitted so users know + they can add ``@tool`` explicitly to silence it. + + Args: + obj: The object retrieved from a module by name. + name: The attribute name, used in log/error messages. + + Returns: + An ``AgentTool`` instance. + + Raises: + TypeError: If *obj* is not callable and cannot be wrapped as a tool. + """ + if isinstance(obj, AgentTool): + return obj + if callable(obj): + logger.warning("tool=<%s> | not decorated with @tool, wrapping automatically", name) + return tool(obj) + raise TypeError(f"'{name}' is not callable and cannot be used as a tool.") + + +def load_tools_from_file(path: str | Path) -> list[AgentTool]: + """Load tools from a Python file. + + Imports the file as a module and collects all ``@tool``-decorated objects. + Plain functions without the ``@tool`` decorator are silently ignored — + users must decorate their functions explicitly. + + Args: + path: Path to a .py file containing tool functions. + + Returns: + List of AgentTool instances found in the file. + + Raises: + FileNotFoundError: If the file does not exist. + ImportError: If the file cannot be loaded as a module. + """ + module = load_module_from_file(path) + return _collect_tools(module) + + +def load_tools_from_module(module_path: str) -> list[AgentTool]: + """Load all @tool decorated functions from a Python module. + + Args: + module_path: Dotted import path (e.g., "my_package.tools"). + + Returns: + List of AgentTool instances found in the module. + + Raises: + ImportError: If the module cannot be imported. + """ + module = importlib.import_module(module_path) + return _collect_tools(module) + + +def load_tool_function(spec: str) -> AgentTool: + """Load a specific tool function from a module. + + If the named function is already decorated with ``@tool`` it is returned + as-is. If it is a plain callable it is automatically wrapped with + ``@tool`` and a warning is logged — users should consider adding the + decorator explicitly. + + Args: + spec: Colon-separated string in "module.path:function_name" format. + + Returns: + An ``AgentTool`` instance for the named function. + + Raises: + ValueError: If the spec format is invalid. + ImportError: If the module cannot be imported. + AttributeError: If the function does not exist in the module. + TypeError: If the named attribute is not callable. + """ + if ":" not in spec: + raise ValueError(f"Invalid tool spec format, expected 'module:function': {spec}") + + module_path, function_name = spec.rsplit(":", 1) + module = importlib.import_module(module_path) + if not hasattr(module, function_name): + raise AttributeError(f"Module '{module_path}' has no attribute '{function_name}'") + return _ensure_tool(getattr(module, function_name), function_name) + + +def load_tools_from_directory(path: str | Path) -> list[AgentTool]: + """Load all @tool functions from .py files in a directory. + + Scans all .py files (excluding ``_``-prefixed) and loads their tools. + + Args: + path: Directory path to scan. + + Returns: + List of AgentTool instances from all files in the directory. + + Raises: + FileNotFoundError: If the directory does not exist. + NotADirectoryError: If the path is not a directory. + """ + dir_path = Path(path).resolve() + if not dir_path.exists(): + raise FileNotFoundError(f"Tool directory not found: {dir_path}") + if not dir_path.is_dir(): + raise NotADirectoryError(f"Path is not a directory: {dir_path}") + + tools: list[AgentTool] = [] + for py_file in sorted(dir_path.glob("*.py")): + if py_file.name.startswith("_"): + logger.debug("file=<%s> | skipping underscore-prefixed file", py_file.name) + continue + loaded = load_tools_from_file(py_file) + tools.extend(loaded) + return tools + + +def resolve_tool_spec(spec: str) -> list[AgentTool]: + r"""Resolve a tool specification string to tool objects. + + Supported formats (checked in order): + + - ``"./path/to/file.py:function_name"`` — single tool from a file + - ``"./path/to/dir/"`` or ``"./path/to/dir"`` (is a directory) — all tools in dir + - ``"./path/to/file.py"`` or ``"path/to/file.py"`` — all tools from file + - ``"module.path:function_name"`` — single tool from module + - ``"module.path"`` — all tools from module + + The heuristic for path vs. module: if the spec contains ``/``, ``\\``, or + ends with ``.py`` (before any ``:``) it is treated as a filesystem path. + + Args: + spec: Tool specification string. + + Returns: + List of tool objects. + """ + # Determine the path part (before colon, if present) + path_part = spec.rsplit(":", 1)[0] if ":" in spec else spec + is_fs_path = "/" in path_part or "\\" in path_part or path_part.endswith(".py") + + if is_fs_path: + if ":" in spec: + # ./path/to/file.py:function_name — single function from a file + file_str, func_name = spec.rsplit(":", 1) + module = load_module_from_file(file_str) + if not hasattr(module, func_name): + raise AttributeError( + f"Tool file '{Path(file_str).resolve()}' has no attribute '{func_name}'" + ) + return [_ensure_tool(getattr(module, func_name), func_name)] + + # No colon: file or directory + candidate = Path(spec) + if candidate.is_dir() or spec.endswith("/") or spec.endswith("\\"): + return list(load_tools_from_directory(candidate)) + + return list(load_tools_from_file(spec)) + + # Module-based specs + if ":" in spec: + return [load_tool_function(spec)] + + return list(load_tools_from_module(spec)) + + +def resolve_tool_specs(specs: list[str]) -> list[AgentTool]: + """Resolve a list of tool specification strings. + + Args: + specs: List of tool specification strings. + + Returns: + Flat list of all resolved tool objects. + """ + tools: list[AgentTool] = [] + for spec in specs: + tools.extend(resolve_tool_spec(spec)) + return tools diff --git a/src/strands_compose/tools/wrappers.py b/src/strands_compose/tools/wrappers.py new file mode 100644 index 0000000..05043bf --- /dev/null +++ b/src/strands_compose/tools/wrappers.py @@ -0,0 +1,100 @@ +"""Node wrapping utilities for delegation. + +Provides ``node_as_tool`` and ``node_as_async_tool`` for wrapping +``Agent`` / ``MultiAgentBase`` nodes as ``AgentTool`` instances. + +Key Features: + - Sync and async tool wrappers for Agent and MultiAgentBase nodes + - Automatic tool name resolution from agent_id or node id + - Text extraction from both single-agent and multi-agent results +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from strands import Agent +from strands.tools.decorator import DecoratedFunctionTool, tool + +from .extractors import extract_last_text_block + +if TYPE_CHECKING: + from ..types import Node + + +def _resolve_tool_name(node: Node, name: str | None) -> str: + """Resolve the tool name for a node. + + For ``Agent`` nodes, defaults to ``agent.agent_id``. + For ``MultiAgentBase`` nodes, defaults to ``node.id`` or ``"sub_orchestration"``. + + Args: + node: Agent or MultiAgentBase instance. + name: Explicit tool name override, or ``None`` to use the default. + + Returns: + The resolved tool name string. + """ + if name is not None: + return name + if isinstance(node, Agent): + return node.agent_id + return getattr(node, "id", "sub_orchestration") + + +def node_as_tool( + node: Node, + *, + name: str | None = None, + description: str, +) -> DecoratedFunctionTool: + """Wrap an Agent or MultiAgentBase as an ``AgentTool`` for delegation. + + For Agent nodes, invokes the agent and extracts text from the response. + For MultiAgentBase nodes (Swarm, Graph), invokes and stringifies the result. + + Args: + node: Agent or MultiAgentBase instance. + name: Tool name (defaults to node id). + description: Tool description for the parent LLM. + + Returns: + An ``AgentTool`` (``DecoratedFunctionTool``) wrapping the node. + """ + tool_name = _resolve_tool_name(node, name) + + @tool(name=tool_name, description=description) + def delegate(input: str) -> str: + result = node(input) + return extract_last_text_block(result) + + return delegate + + +def node_as_async_tool( + node: Node, + *, + name: str | None = None, + description: str, +) -> DecoratedFunctionTool: + """Wrap an Agent or MultiAgentBase as an async ``AgentTool`` for delegation. + + For Agent nodes, uses ``invoke_async`` for live event streaming. + For MultiAgentBase nodes, awaits ``invoke_async``. + + Args: + node: Agent or MultiAgentBase instance. + name: Tool name. + description: Tool description for the parent LLM. + + Returns: + An ``AgentTool`` (``DecoratedFunctionTool``) wrapping the node. + """ + tool_name = _resolve_tool_name(node, name) + + @tool(name=tool_name, description=description) + async def delegate(input: str) -> str: + result = await node.invoke_async(input) + return extract_last_text_block(result) + + return delegate diff --git a/src/strands_compose/types.py b/src/strands_compose/types.py new file mode 100644 index 0000000..5fb2f28 --- /dev/null +++ b/src/strands_compose/types.py @@ -0,0 +1,110 @@ +"""Shared types for the core package. + +Centralises the ``Node`` union so it is defined exactly once and +imported everywhere else, rather than being duplicated in +``orchestration`` and ``config/resolvers``. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from enum import StrEnum +from typing import Any + +from strands import Agent +from strands.multiagent.base import MultiAgentBase + +# A node is either a plain Agent or a built multi-agent orchestration +# (Swarm, Graph, or any other MultiAgentBase subclass). +Node = Agent | MultiAgentBase + + +class EventType(StrEnum): + """Event type constants for :class:`StreamEvent`. + + ``StrEnum`` values are plain strings, so ``== "token"`` comparisons + work unchanged. + """ + + # Single-agent events + AGENT_START = "agent_start" + TOKEN = "token" # nosec B105 — event type constant, not a credential + TOOL_START = "tool_start" + TOOL_END = "tool_end" + REASONING = "reasoning" + COMPLETE = "complete" + ERROR = "error" + + # Multi-agent events + NODE_START = "node_start" + NODE_STOP = "node_stop" + HANDOFF = "handoff" + MULTIAGENT_START = "multiagent_start" + MULTIAGENT_COMPLETE = "multiagent_complete" + + +@dataclass +class StreamEvent: + """A typed event from agent or multi-agent execution. + + Produced by :class:`~strands_compose.hooks.EventPublisher` for + all agent activity: ``TOKEN``, ``REASONING``, ``TOOL_START``, ``TOOL_END``, + ``COMPLETE``, ``ERROR``, ``NODE_START``, ``NODE_STOP``, + ``HANDOFF``, ``MULTIAGENT_COMPLETE``. + + Attributes: + type: Event type identifier (one of the :class:`EventType` values). + agent_name: Name of the agent that produced this event. + timestamp: When the event occurred. + data: Event-specific payload. + """ + + type: str + agent_name: str + timestamp: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc)) + data: dict[str, Any] = field(default_factory=dict) + + def asdict(self) -> dict[str, Any]: + """Convert this StreamEvent to a flat dict for serialization.""" + data = asdict(self) + data["timestamp"] = self.timestamp.isoformat() + return data + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> StreamEvent: + """Deserialize a dict into a StreamEvent. + + Restores all fields produced by :meth:`asdict`, including the + ``timestamp`` (parsed from its ISO-8601 string representation). + + Args: + data: A dict as produced by :meth:`asdict`. + + Returns: + A new StreamEvent instance. + """ + ts_raw = data.get("timestamp") + if isinstance(ts_raw, str): + ts = datetime.fromisoformat(ts_raw) + elif isinstance(ts_raw, datetime): + ts = ts_raw + else: + ts = datetime.now(tz=timezone.utc) + + return cls( + type=data.get("type", ""), + agent_name=data.get("agent_name", ""), + timestamp=ts, + data=data.get("data", {}), + ) + + def __eq__(self, other: object) -> bool: + """Compare events by type, agent_name, and data (ignoring timestamp).""" + if not isinstance(other, StreamEvent): + return NotImplemented + return (self.type, self.agent_name, self.data) == (other.type, other.agent_name, other.data) + + def __hash__(self) -> int: + """Hash based on type and agent_name (data is unhashable).""" + return hash((self.type, self.agent_name)) diff --git a/src/strands_compose/utils.py b/src/strands_compose/utils.py new file mode 100644 index 0000000..c25655c --- /dev/null +++ b/src/strands_compose/utils.py @@ -0,0 +1,210 @@ +"""Shared low-level utilities for the strands_compose package.""" + +from __future__ import annotations + +import contextlib +import hashlib +import importlib +import importlib.util +import logging +import sys +from collections.abc import Iterator +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from .exceptions import ImportResolutionError + +if TYPE_CHECKING: + from types import ModuleType + + +def import_from_path(import_path: str) -> Any: + """Import a Python object from a ``module.path:ObjectName`` string. + + Args: + import_path: Import string in ``"module.path:ObjectName"`` format. + + Returns: + The imported object. + + Raises: + ValueError: If format is invalid (missing ``:``)." + ImportError: If module cannot be imported. + AttributeError: If object does not exist in module. + """ + if ":" not in import_path: + raise ValueError( + f"Import path must be in 'module.path:ObjectName' format, got: {import_path!r}" + ) + module_path, obj_name = import_path.rsplit(":", 1) + module = importlib.import_module(module_path) + return getattr(module, obj_name) + + +def load_object(spec: str, *, target: str = "object") -> Any: + """Load a Python object from a module-path or file-path spec. + + This is the **unified entry point** for resolving any + ``module.path:ObjectName`` or ``./file.py:ObjectName`` import spec + used throughout the config layer (agent factories, MCP server + factories, model classes, session manager classes, hook classes, + graph-edge conditions, etc.). + + The ``target`` kwarg is used **only** in error messages so that + failures clearly identify what was being loaded. + + Args: + spec: Import spec — either ``"module.path:ObjectName"`` or a + filesystem path (containing ``/`` or ``\\``) with a colon- + separated attribute, e.g. ``"/abs/path/file.py:create"``. + target: Human-readable label for error messages, e.g. + ``"agent factory"``, ``"MCP server"``, ``"graph condition"``. + + Returns: + The imported Python object. + + Raises: + ImportResolutionError: If the spec is malformed, the module/file + cannot be loaded, or the attribute does not exist. + """ + if ":" not in spec: + raise ImportResolutionError( + f"{target.capitalize()} import spec must be in 'module.path:Name' " + f"or './file.py:Name' format, got: {spec!r}" + ) + + path_part, obj_name = spec.rsplit(":", 1) + is_file_path = "/" in path_part or "\\" in path_part + + try: + if is_file_path: + module = load_module_from_file(path_part) + if not hasattr(module, obj_name): + raise ImportResolutionError( + f"{target.capitalize()} file '{path_part}' has no attribute '{obj_name}'" + ) + return getattr(module, obj_name) + + return import_from_path(spec) + except ImportResolutionError: + raise + except (ImportError, FileNotFoundError, AttributeError) as exc: + raise ImportResolutionError(f"Failed to load {target} from '{spec}': {exc}") from exc + + +def load_module_from_file(path: str | Path) -> ModuleType: + """Load a Python file as a module. + + Uses a deterministic module name based on the file's absolute path + so repeated loads of the same file reuse the same name. + + Args: + path: Path to a ``.py`` file. + + Returns: + The loaded module. + + Raises: + FileNotFoundError: If the file does not exist. + ImportError: If the file cannot be loaded. + """ + file_path = Path(path).resolve() + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + + path_hash = hashlib.md5(str(file_path).encode(), usedforsecurity=False).hexdigest()[:12] + module_name = f"_strands_compose_{file_path.stem}_{path_hash}" + + spec = importlib.util.spec_from_file_location(module_name, file_path) + if spec is None or spec.loader is None: + raise ImportError(f"Cannot create module spec for: {file_path}") + + if module_name in sys.modules: + del sys.modules[module_name] + + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + try: + spec.loader.exec_module(module) + except Exception as exc: + sys.modules.pop(module_name, None) + raise ImportError(f"Failed to load file {file_path}: {exc}") from exc + + # Remove from sys.modules to avoid polluting the global module namespace. + # The returned module object remains usable — + # only the sys.modules entry is dropped. + # This means subsequent ``import `` statements won't resolve, + # It's intentional: these are user-provided files, not library modules. + # For hot-reload, the ``del`` above ensures a fresh exec on every call. + sys.modules.pop(module_name, None) + return module + + +# ── CLI error formatting ────────────────────────────────────────────────────── + + +class _SuppressTaskExceptions(logging.Filter): + """Suppress asyncio 'Task exception was never retrieved' log entries. + + These originate from strands' internal event loop when exceptions + escape sub-tasks. They are already surfaced via the ERROR StreamEvent + emitted by EventPublisher, so the raw asyncio traceback is redundant. + """ + + def filter(self, record: logging.LogRecord) -> bool: # noqa: A003 + return "exception was never retrieved" not in record.getMessage() + + +def _format_exception(exc: BaseException) -> str: + """Format an exception as ``module.ClassName: message`` (multi-line). + + For exceptions whose ``__module__`` is ``builtins`` the module prefix is + omitted so that ``ValueError: …`` is shown instead of ``builtins.ValueError: …``. + """ + cls = type(exc) + module = getattr(cls, "__module__", None) or "" + qualname = cls.__qualname__ + prefix = f"{module}.{qualname}" if module and module != "builtins" else qualname + return f"{prefix}:\n{exc}" if str(exc) else prefix + + +@contextlib.contextmanager +def cli_errors(*, exit_code: int = 1) -> Iterator[None]: + """Catch unhandled exceptions and print a clean, user-friendly message. + + .. warning:: + + **CLI-only** — this context manager calls ``sys.exit()`` on errors, + which raises ``SystemExit``. Do **not** use it in ASGI/WSGI server + code (FastAPI, Flask, etc.) where ``SystemExit`` would kill the + worker process. For server code, catch exceptions directly. + + Intended for CLI entry points — wraps the body so that configuration + errors, auth failures, network problems, etc. are displayed without a + full Python traceback:: + + with cli_errors(): + asyncio.run(main()) + + ``KeyboardInterrupt`` and ``SystemExit`` are **not** caught. + + Args: + exit_code: Process exit code used after printing the error. + Set to ``0`` to suppress ``sys.exit`` (useful in tests). + """ + _asyncio = logging.getLogger("asyncio") + _filter = _SuppressTaskExceptions() + _asyncio.addFilter(_filter) + try: + yield + except (KeyboardInterrupt, SystemExit): + raise + except Exception as exc: + msg = f"\n{_format_exception(exc)}\n" + if sys.stderr.isatty(): + msg = f"\033[31m{msg}\033[0m" + print(msg, file=sys.stderr) # noqa: T201 + if exit_code: + sys.exit(exit_code) + finally: + _asyncio.removeFilter(_filter) diff --git a/src/strands_compose/wire.py b/src/strands_compose/wire.py new file mode 100644 index 0000000..456ed9c --- /dev/null +++ b/src/strands_compose/wire.py @@ -0,0 +1,196 @@ +"""Event queue wiring for streaming agent activities. + +:class:`~strands_compose.types.StreamEvent` is the core event produced by +:class:`~strands_compose.hooks.EventPublisher` for all agent activity. + +:class:`EventQueue` is a thin async queue wrapper that hides the sentinel +pattern from callers. :func:`make_event_queue` attaches +:class:`~strands_compose.hooks.EventPublisher` hooks to every agent +so all events (TOKEN, REASONING, TOOL_START, TOOL_END, COMPLETE, +and — for Swarm/Graph — NODE_START, NODE_STOP, HANDOFF, MULTIAGENT_COMPLETE) +flow into the shared queue. + +Hooks are wired **once per session**. Between requests on the same session, +call :meth:`EventQueue.flush` to discard stale events. + +Key Features: + - Async queue with hidden end-of-stream sentinel pattern + - Thread-safe event injection for cross-thread publishing + - Automatic EventPublisher wiring for agents and orchestrators +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import TYPE_CHECKING + +from strands import Agent +from strands.multiagent import Swarm +from strands.multiagent.graph import Graph + +from .hooks import EventPublisher +from .types import StreamEvent + +if TYPE_CHECKING: + from .types import Node + + +logger = logging.getLogger(__name__) + + +# ── Event Queue ────────────────────────────────────────────────────────────── + +# Private sentinel — never exposed outside this module. +_SENTINEL = object() + + +class EventQueue: + """Async event queue with a hidden end-of-stream sentinel.""" + + def __init__(self, queue: asyncio.Queue) -> None: + """Initialize the EventQueue. + + Callers consume events via :meth:`get` (which returns ``None`` when the + stream is closed) and signal completion via :meth:`close`. The sentinel + is an implementation detail — user code never sees or owns it. + + Example:: + + events = make_event_queue(config.agents) + + + async def _run(): + try: + await config.entry.invoke_async(prompt) + finally: + await events.close() + + + asyncio.create_task(_run()) + while (event := await events.get()) is not None: + yield event.asdict() + + Args: + queue: The underlying asyncio.Queue to wrap. + """ + self._queue = queue + + # -- Internal ---------------------------------------------------------- + + def _put(self, event: StreamEvent | object) -> None: + """Place an item on the queue (non-blocking). + + Used as the :class:`~strands_compose.hooks.EventPublisher` callback. + Drops the event with a warning when the queue is full. + """ + try: + self._queue.put_nowait(event) + except asyncio.QueueFull: + if event is not _SENTINEL: + logger.warning( + "maxsize=<%d> | event queue full, dropping event", self._queue.maxsize + ) + + # -- Public API -------------------------------------------------------- + + def flush(self) -> None: + """Discard all currently queued events. + + Call this at the start of each request to clear any stale events + left over from a previous invocation. + """ + while not self._queue.empty(): + self._queue.get_nowait() + + async def get(self) -> StreamEvent | None: + """Wait for the next event. + + Returns: + The next :class:`StreamEvent`, or ``None`` when the stream + has been closed via :meth:`close`. + """ + item = await self._queue.get() + return None if item is _SENTINEL else item + + def put_event(self, event: StreamEvent) -> None: + """Place an event on the queue (non-blocking, thread-safe). + + Useful for injecting out-of-band events such as error signals. + """ + self._put(event) + + async def close(self) -> None: + """Signal end-of-stream. + + Places the sentinel on the queue so that the consumer loop + terminates cleanly. Typically called in a ``finally`` block after + the agent invocation finishes. + """ + await self._queue.put(_SENTINEL) + + +def make_event_queue( + agents: dict[str, Agent], + *, + orchestrators: dict[str, Node] | None = None, + tool_labels: dict[str, str] | None = None, +) -> EventQueue: + """Attach :class:`~strands_compose.hooks.EventPublisher` hooks to agents. + + Every agent in *agents* receives an :class:`.EventPublisher` hook and a + matching ``callback_handler`` so that all event types flow into the + returned :class:`EventQueue`. + + Orchestrators (Swarm / Graph) in *orchestrators* also get a publisher + for NODE_START, NODE_STOP, HANDOFF, and MULTIAGENT_COMPLETE events. + + .. warning:: + + This function **mutates** the passed-in agents and orchestrators + by adding hooks and overwriting ``callback_handler``. Call it + only once per set of agents. For the common ``ResolvedConfig`` + workflow, prefer :meth:`ResolvedConfig.wire_event_queue` which + makes the mutation explicit. + + Args: + agents: Agents to wire, keyed by name. + orchestrators: Built orchestrations keyed by name. + tool_labels: Tool name -> display label mapping forwarded to each + :class:`.EventPublisher`. Defaults to + ``{name: "Delegating work to agent: "}`` for every agent. + + Returns: + A ready-to-use :class:`EventQueue`. + """ + event_queue = EventQueue(asyncio.Queue(maxsize=10000)) + + labels = { + **{name: f"Delegating work to agent: {name.title()}" for name in agents}, + **(tool_labels or {}), + } + + # Wire every agent with a publisher. + for name, agent in agents.items(): + pub = EventPublisher(callback=event_queue._put, agent_name=name, tool_labels=labels) + agent.hooks.add_hook(pub) + agent.callback_handler = pub.as_callback_handler() + logger.debug("agent=<%s> | wired EventPublisher", name) + + # Wire orchestrators (Swarm / Graph instances). + for orch_name, orch in (orchestrators or {}).items(): + if isinstance(orch, (Swarm, Graph, Agent)): + orch_pub = EventPublisher( + callback=event_queue._put, + agent_name=orch_name, + tool_labels=labels, + ) + orch.hooks.add_hook(orch_pub) + + # If orch is an Agent, it needs the callback_handler set like any other agent. + if isinstance(orch, Agent): + orch.callback_handler = orch_pub.as_callback_handler() + + logger.debug("orchestrator=<%s> | wired EventPublisher", orch_name) + + return event_queue diff --git a/tasks/README.md b/tasks/README.md new file mode 100644 index 0000000..0f75be9 --- /dev/null +++ b/tasks/README.md @@ -0,0 +1,152 @@ +# Project Tasks + +This directory contains Just tasks for automating common project operations. Each task file is focused on a specific aspect of the project. + +## Task Groups + +### Clean Tasks (`clean.just`) +Tasks for cleaning project files and caches: + +- `clean`: Run all clean tasks + ```bash + uv run just clean + ``` + +- `clean-python`: Clean Python cache files (in src, tests, notebooks) + ```bash + uv run just clean-python + ``` + +- `clean-cache`: Clean .cache directory + ```bash + uv run just clean-cache + ``` + +- `clean-ty`: Clean ty cache + ```bash + uv run just clean-ty + ``` + +- `clean-pytest`: Clean pytest cache + ```bash + uv run just clean-pytest + ``` + +- `clean-ruff`: Clean ruff cache + ```bash + uv run just clean-ruff + ``` + +- `clean-venv`: Clean virtual environment (requires confirmation) + ```bash + uv run just clean-venv + ``` + +### Check Tasks (`check.just`) +Tasks for running code quality checks: + +- `check`: Run all checks + ```bash + uv run just check + ``` + +- `check-lint`: Run linting checks + ```bash + uv run just check-lint + ``` + +- `check-type`: Run type checking + ```bash + uv run just check-type + ``` + +- `check-test`: Run tests + ```bash + uv run just check-test + ``` + +### Format Tasks (`format.just`) +Tasks for code formatting: + +- `format`: Format all code + ```bash + uv run just format + ``` + +- `format-check`: Check if code is formatted correctly + ```bash + uv run just format-check + ``` + +### Install Tasks (`install.just`) +Tasks for managing dependencies: + +- `install`: Install all dependencies **and** wire git hooks (run this after every fresh clone) + ```bash + uv run just install + ``` + +- `install-project`: Install Python dependencies only (no hooks) + ```bash + uv run just install-project + ``` + +- `install-hooks`: Register pre-commit hooks into `.git/hooks/` (pre-commit, pre-push, commit-msg) + ```bash + uv run just install-hooks + ``` + +### Commit Tasks (`commit.just`) +Tasks for managing commits: + +- **`commit-bump`**: Bump the version of the package using Commitizen. + ```bash + uv run just commit-bump + ``` + +- **`commit-files`**: Create a conventional commit using Commitizen. + ```bash + uv run just commit-files + ``` + +- **`commit-info`**: Retrieve commit information using Commitizen. + ```bash + uv run just commit-info + ``` + +- **`check-hooks`**: Run all pre-commit hooks to ensure code quality. + ```bash + uv run just check-hooks + ``` + +## Usage + +1. Install Just: + ```bash + # On Ubuntu/Debian + sudo apt install just + + # On macOS + brew install just + ``` + +2. Run tasks: + ```bash + uv run just + ``` + +3. List all available tasks: + ```bash + uv run just --list + ``` + +4. Get help for a specific task: + ```bash + uv run just --help + ``` + +## Task Dependencies + +Some tasks depend on others. For example: +- `clean` runs all clean tasks +- `check` runs all check tasks diff --git a/tasks/check.just b/tasks/check.just new file mode 100644 index 0000000..8416efb --- /dev/null +++ b/tasks/check.just @@ -0,0 +1,30 @@ +# run check tasks +[group('check')] +check: check-format check-code check-type check-security + +# check code quality +[group('check')] +check-code: + uv run ruff check {{SOURCES}} {{TESTS}} {{EXAMPLES}} + +# check code format +[group('check')] +check-format: + uv run ruff check --select=I --fix {{SOURCES}} {{TESTS}} {{EXAMPLES}} + uv run ruff format {{SOURCES}} {{TESTS}} {{EXAMPLES}} + uv run ruff format --check {{SOURCES}} {{TESTS}} {{EXAMPLES}} + +# check code security +[group('check')] +check-security: + uv run python -m bandit --recursive --configfile=pyproject.toml {{SOURCES}} {{EXAMPLES}} + +# check unit tests +[group('check')] +check-test numprocesses="auto": + uv run python -m pytest --numprocesses={{numprocesses}} {{TESTS}} + +# check code typing +[group('check')] +check-type: + uv run ty check {{SOURCES}} {{TESTS}} {{EXAMPLES}} diff --git a/tasks/clean.just b/tasks/clean.just new file mode 100644 index 0000000..1c12139 --- /dev/null +++ b/tasks/clean.just @@ -0,0 +1,55 @@ +# run all clean tasks +[group('clean')] +clean: clean-build clean-cache clean-constraints clean-coverage clean-ty clean-pytest clean-python clean-requirements clean-ruff + +# clean build folders +[group('clean')] +clean-build: + uv run python -c "import shutil; [shutil.rmtree(d, ignore_errors=True) for d in ['dist', 'build']]" + +# clean cache folder +[group('clean')] +clean-cache: + uv run python -c "import shutil; shutil.rmtree('.cache', ignore_errors=True)" + +# clean constraints file +[group('clean')] +clean-constraints: + uv run python -c "import pathlib; pathlib.Path('constraints.txt').unlink(missing_ok=True)" + +# clean coverage files +[group('clean')] +clean-coverage: + uv run python -c "import pathlib; [p.unlink() for p in pathlib.Path('.').glob('.coverage*') if p.is_file()]" + +# clean ty cache +[group('clean')] +clean-ty: + uv run python -c "import shutil; shutil.rmtree('.ty_cache', ignore_errors=True)" + +# clean pytest cache +[group('clean')] +clean-pytest: + uv run python -c "import shutil; shutil.rmtree('.pytest_cache', ignore_errors=True)" + +# clean python caches (__pycache__ and .pyc/.pyo files) +[group('clean')] +clean-python: + uv run python -c "import pathlib, shutil; [shutil.rmtree(str(d)) for d in pathlib.Path('.').rglob('__pycache__') if d.is_dir()]" + uv run python -c "import pathlib; [p.unlink() for p in pathlib.Path('.').rglob('*.py[co]')]" + +# clean requirements file +[group('clean')] +clean-requirements: + uv run python -c "import pathlib; pathlib.Path('requirements.txt').unlink(missing_ok=True)" + +# clean ruff cache +[group('clean')] +clean-ruff: + uv run python -c "import shutil; shutil.rmtree('.ruff_cache', ignore_errors=True)" + +# clean venv folder +[confirm] +[group('clean')] +clean-venv: + uv run python -c "import shutil; shutil.rmtree('.venv', ignore_errors=True)" diff --git a/tasks/commit.just b/tasks/commit.just new file mode 100644 index 0000000..7122228 --- /dev/null +++ b/tasks/commit.just @@ -0,0 +1,19 @@ +# bump package version +[group('commit')] +commit-bump: + uv run cz bump + +# commit package with commitizen +[group('commit')] +commit-files: + uv run cz commit + +# get commit info +[group('commit')] +commit-info: + uv run cz info + +# run pre-commit hooks on all files +[group('commit')] +check-hooks: + uv run pre-commit run --all-files diff --git a/tasks/format.just b/tasks/format.just new file mode 100644 index 0000000..7fcb659 --- /dev/null +++ b/tasks/format.just @@ -0,0 +1,13 @@ +# run format tasks +[group('format')] +format: format-import format-source + +# format code import +[group('format')] +format-import: + uv run ruff check --select=I --fix {{SOURCES}} {{TESTS}} {{EXAMPLES}} + +# format code source +[group('format')] +format-source: + uv run ruff format {{SOURCES}} {{TESTS}} {{EXAMPLES}} diff --git a/tasks/install.just b/tasks/install.just new file mode 100644 index 0000000..754db2c --- /dev/null +++ b/tasks/install.just @@ -0,0 +1,15 @@ +# install git hooks +[group('install')] +install-hooks: + uv run pre-commit install + uv run pre-commit install --hook-type=pre-push + uv run pre-commit install --hook-type=commit-msg + +# install the project +[group('install')] +install-project: + uv sync --all-groups --all-extras + +# run install tasks +[group('install')] +install: install-project install-hooks diff --git a/tasks/release.just b/tasks/release.just new file mode 100644 index 0000000..681897b --- /dev/null +++ b/tasks/release.just @@ -0,0 +1,39 @@ +# --------------------------------------------------------------------------- +# Release tasks +# +# Normal release flow: +# 1. uv run just release-dry -> preview version bump + changelog diff +# 2. uv run just release -> bump version, update CHANGELOG, create tag +# 3. git push origin main --tags -> triggers publish.yml CI/CD +# --------------------------------------------------------------------------- + +# Preview the next version bump (no changes written) +[group('release')] +release-dry: + uv run cz bump --dry-run + +# Bump version, update CHANGELOG, and create a git tag +[group('release')] +release: check test + uv run cz bump + @echo "" + @echo "✅ Version bumped. Push with:" + @echo " git push origin main --tags" + +# Build distribution artifacts (local check) +[group('release')] +release-build: + rm -rf dist/ + uv build --out-dir dist/ + @echo "" + @ls dist/ + +# Publish to TestPyPI (safe dry-run against the test registry) +[group('release')] +release-test-publish: + uv run python -m twine upload --repository testpypi dist/* + +# Show the next version that commitizen would pick +[group('release')] +release-next: + uv run cz bump --dry-run 2>&1 | grep -E "tag to create|bump" || echo "(no releasable commits since last tag)" diff --git a/tasks/test.just b/tasks/test.just new file mode 100644 index 0000000..7a26441 --- /dev/null +++ b/tasks/test.just @@ -0,0 +1,13 @@ +# run test tasks +[group('test')] +test: test-coverage + +# check code coverage +[group('test')] +test-coverage numprocesses="auto" cov_fail_under="70": + uv run python -m pytest --numprocesses={{numprocesses}} --cov={{SOURCES}} --cov-fail-under={{cov_fail_under}} {{TESTS}} + +# run mutation testing (requires: pip install mutmut) +[group('test')] +test-mutation module="src/strands_compose/hooks/event_publisher.py": + uv run mutmut run --paths-to-mutate={{module}} --tests-dir={{TESTS}} --runner="python -m pytest -x -q" diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..49ba283 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""strands-compose test suite.""" diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..95cc65b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +"""Root conftest — registers markers so they are available to all test layers.""" + +from tests.unit.conftest import * # noqa: F401, F403 — re-export shared fixtures + + +def pytest_configure(config): + """Register custom markers.""" + config.addinivalue_line("markers", "integration: Integration tests (full pipeline)") + config.addinivalue_line("markers", "ollama: Tests requiring local Ollama") + config.addinivalue_line("markers", "bedrock: Tests requiring AWS Bedrock") diff --git a/tests/examples/test_examples_smoke.py b/tests/examples/test_examples_smoke.py new file mode 100644 index 0000000..f6bc436 --- /dev/null +++ b/tests/examples/test_examples_smoke.py @@ -0,0 +1,103 @@ +"""Smoke tests for example YAML configs. + +Load every example config through ``load()`` without invoking the agents. +These tests patch external runtime dependencies so they stay independent of +live model providers and MCP availability. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from strands import Agent as _RealAgent + +from strands_compose.config import ResolvedConfig, load + +REPO_ROOT = Path(__file__).resolve().parents[2] +EXAMPLES_DIR = REPO_ROOT / "examples" + + +class _FakeServer: + def start(self) -> None: + pass + + def wait_ready(self, timeout: float) -> bool: + return True + + def stop(self) -> None: + pass + + +class _FakeClient: + def stop(self, exc_type=None, exc_val=None, exc_tb=None) -> None: + pass + + +def _noop_agent_init(self, **kwargs) -> None: + """No-op Agent init that stores just enough state for smoke tests.""" + self._init_kwargs = kwargs + self.agent_id = kwargs.get("agent_id", kwargs.get("name")) + self.name = kwargs.get("name") + self.model = kwargs.get("model") + self.system_prompt = kwargs.get("system_prompt") + self.description = kwargs.get("description") + self.tools = kwargs.get("tools", []) + self.hooks = kwargs.get("hooks", []) + self.callback_handler = kwargs.get("callback_handler") + self.messages = kwargs.get("messages", []) + self.state = {} + self.tool_registry = MagicMock() + self.tool_registry.registry = {} + self.hook_registry = MagicMock() + self._session_manager = kwargs.get("session_manager") + + +def _fake_orchestrations(config, agents, **kwargs): + return {name: MagicMock(name=f"orchestration:{name}") for name in config.orchestrations} + + +def _iter_example_load_inputs() -> list[object]: + params = [] + for example_dir in sorted( + p for p in EXAMPLES_DIR.iterdir() if p.is_dir() and p.name[:2].isdigit() + ): + yaml_files = sorted( + example_dir.glob("*.y*ml"), + key=lambda path: (path.name != "base.yaml", path.name), + ) + if not yaml_files: + continue + load_input = yaml_files[0] if len(yaml_files) == 1 else yaml_files + params.append(pytest.param(load_input, id=example_dir.name)) + return params + + +@pytest.mark.integration +@pytest.mark.parametrize("config_input", _iter_example_load_inputs()) +def test_example_yaml_loads(config_input): + with ( + patch.object(_RealAgent, "__init__", _noop_agent_init), + patch( + "strands_compose.config.resolvers.config.resolve_model", + lambda model_def: MagicMock(name="model"), + ), + patch( + "strands_compose.config.resolvers.config.resolve_mcp_server", + lambda *args, **kwargs: _FakeServer(), + ), + patch( + "strands_compose.config.resolvers.config.resolve_mcp_client", + lambda *args, **kwargs: _FakeClient(), + ), + patch( + "strands_compose.config.loaders.loaders.resolve_orchestrations", _fake_orchestrations + ), + ): + resolved = load(config_input) + + assert isinstance(resolved, ResolvedConfig) + assert resolved.entry is not None + + resolved.mcp_lifecycle.stop() diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..244cee1 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,28 @@ +"""Shared fixtures for integration tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def fixtures_dir() -> Path: + """Path to the integration test fixtures directory.""" + return FIXTURES_DIR + + +@pytest.fixture +def fixture_path(): + """Return a factory that resolves a fixture name to its full path.""" + + def _resolve(name: str) -> str: + path = FIXTURES_DIR / name + if not path.exists(): + raise FileNotFoundError(f"Fixture not found: {path}") + return str(path) + + return _resolve diff --git a/tests/integration/fixtures/complex_full.yaml b/tests/integration/fixtures/complex_full.yaml new file mode 100644 index 0000000..5e4dd85 --- /dev/null +++ b/tests/integration/fixtures/complex_full.yaml @@ -0,0 +1,50 @@ +vars: + default_model: anthropic.claude-3-haiku-20240307-v1:0 + +models: + fast_model: + provider: bedrock + model_id: "${default_model}" + smart_model: + provider: bedrock + model_id: anthropic.claude-3-sonnet-20240229-v1:0 + +agents: + researcher: + model: fast_model + system_prompt: "You are a research specialist." + description: "Researches topics and gathers information." + writer: + model: smart_model + system_prompt: "You are a professional writer." + description: "Writes polished content." + hooks: + - type: strands_compose.hooks:MaxToolCallsGuard + params: + max_calls: 10 + editor: + model: fast_model + system_prompt: "You edit content for clarity." + description: "Edits and improves text." + reviewer: + model: smart_model + system_prompt: "You review content quality." + description: "Final review and approval." + +entry: content_pipeline + +orchestrations: + writing_team: + mode: delegate + entry_name: writer + connections: + - agent: researcher + description: "Research the topic first." + content_pipeline: + mode: graph + entry_name: writing_team + edges: + - from: writing_team + to: editor + - from: editor + to: reviewer diff --git a/tests/integration/fixtures/graph.yaml b/tests/integration/fixtures/graph.yaml new file mode 100644 index 0000000..c4a3a2a --- /dev/null +++ b/tests/integration/fixtures/graph.yaml @@ -0,0 +1,17 @@ +agents: + collector: + system_prompt: "You collect data." + analyzer: + system_prompt: "You analyze data." + summarizer: + system_prompt: "You summarize results." +entry: pipeline +orchestrations: + pipeline: + mode: graph + entry_name: collector + edges: + - from: collector + to: analyzer + - from: analyzer + to: summarizer diff --git a/tests/integration/fixtures/minimal.yaml b/tests/integration/fixtures/minimal.yaml new file mode 100644 index 0000000..70534dd --- /dev/null +++ b/tests/integration/fixtures/minimal.yaml @@ -0,0 +1,4 @@ +agents: + greeter: + system_prompt: "You are a helpful assistant." +entry: greeter diff --git a/tests/integration/fixtures/multi_agent_delegate.yaml b/tests/integration/fixtures/multi_agent_delegate.yaml new file mode 100644 index 0000000..0167372 --- /dev/null +++ b/tests/integration/fixtures/multi_agent_delegate.yaml @@ -0,0 +1,13 @@ +agents: + researcher: + system_prompt: "You are a research assistant." + writer: + system_prompt: "You are a technical writer." +entry: coordinator +orchestrations: + coordinator: + mode: delegate + entry_name: writer + connections: + - agent: researcher + description: "Research a topic in depth." diff --git a/tests/integration/fixtures/nested_orchestration.yaml b/tests/integration/fixtures/nested_orchestration.yaml new file mode 100644 index 0000000..5ce21bb --- /dev/null +++ b/tests/integration/fixtures/nested_orchestration.yaml @@ -0,0 +1,23 @@ +agents: + researcher: + system_prompt: "You research topics." + writer: + system_prompt: "You write content." + editor: + system_prompt: "You edit and improve content." + reviewer: + system_prompt: "You review final content." +entry: full_pipeline +orchestrations: + writing_team: + mode: delegate + entry_name: writer + connections: + - agent: researcher + description: "Research a topic." + full_pipeline: + mode: delegate + entry_name: reviewer + connections: + - agent: writing_team + description: "Run the full writing pipeline." diff --git a/tests/integration/fixtures/swarm.yaml b/tests/integration/fixtures/swarm.yaml new file mode 100644 index 0000000..c6cedbd --- /dev/null +++ b/tests/integration/fixtures/swarm.yaml @@ -0,0 +1,12 @@ +agents: + analyst: + system_prompt: "You are a data analyst." + reporter: + system_prompt: "You are a reporter." +entry: team +orchestrations: + team: + mode: swarm + agents: [analyst, reporter] + entry_name: analyst + max_handoffs: 10 diff --git a/tests/integration/fixtures/with_hooks.yaml b/tests/integration/fixtures/with_hooks.yaml new file mode 100644 index 0000000..d734528 --- /dev/null +++ b/tests/integration/fixtures/with_hooks.yaml @@ -0,0 +1,8 @@ +agents: + assistant: + system_prompt: "You are helpful." + hooks: + - type: strands_compose.hooks:MaxToolCallsGuard + params: + max_calls: 5 +entry: assistant diff --git a/tests/integration/fixtures/with_model.yaml b/tests/integration/fixtures/with_model.yaml new file mode 100644 index 0000000..90ddf31 --- /dev/null +++ b/tests/integration/fixtures/with_model.yaml @@ -0,0 +1,9 @@ +models: + bedrock_model: + provider: bedrock + model_id: anthropic.claude-3-haiku-20240307-v1:0 +agents: + assistant: + model: bedrock_model + system_prompt: "You are a helpful assistant." +entry: assistant diff --git a/tests/integration/fixtures/with_session_manager.yaml b/tests/integration/fixtures/with_session_manager.yaml new file mode 100644 index 0000000..b7d51ef --- /dev/null +++ b/tests/integration/fixtures/with_session_manager.yaml @@ -0,0 +1,8 @@ +session_manager: + provider: file + params: + session_id: test-session +agents: + assistant: + system_prompt: "You are helpful." +entry: assistant diff --git a/tests/integration/fixtures/with_vars.yaml b/tests/integration/fixtures/with_vars.yaml new file mode 100644 index 0000000..96f7977 --- /dev/null +++ b/tests/integration/fixtures/with_vars.yaml @@ -0,0 +1,12 @@ +vars: + model_provider: bedrock + model_id: anthropic.claude-3-haiku-20240307-v1:0 +models: + main_model: + provider: "${model_provider}" + model_id: "${model_id}" +agents: + assistant: + model: main_model + system_prompt: "You are helpful." +entry: assistant diff --git a/tests/integration/test_full_pipeline.py b/tests/integration/test_full_pipeline.py new file mode 100644 index 0000000..23870cc --- /dev/null +++ b/tests/integration/test_full_pipeline.py @@ -0,0 +1,90 @@ +"""Integration tests for load() → ResolvedConfig full pipeline.""" + +from __future__ import annotations + +import pytest +from strands import Agent + +from strands_compose.config import ResolvedConfig, load +from strands_compose.mcp import MCPLifecycle +from strands_compose.wire import EventQueue + + +@pytest.mark.integration +class TestLoadMinimalPipeline: + """Full load() pipeline with the minimal fixture (single agent, no model).""" + + def test_load_returns_resolved_config(self, fixture_path): + resolved = load(fixture_path("minimal.yaml")) + assert isinstance(resolved, ResolvedConfig) + + def test_entry_is_agent(self, fixture_path): + resolved = load(fixture_path("minimal.yaml")) + assert isinstance(resolved.entry, Agent) + + def test_agents_populated(self, fixture_path): + resolved = load(fixture_path("minimal.yaml")) + assert "greeter" in resolved.agents + assert isinstance(resolved.agents["greeter"], Agent) + + def test_wire_event_queue_returns_queue(self, fixture_path): + resolved = load(fixture_path("minimal.yaml")) + eq = resolved.wire_event_queue() + assert isinstance(eq, EventQueue) + + def test_mcp_lifecycle_idempotent(self, fixture_path): + resolved = load(fixture_path("minimal.yaml")) + assert isinstance(resolved.mcp_lifecycle, MCPLifecycle) + # start() is idempotent — already called by load() + resolved.mcp_lifecycle.start() + resolved.mcp_lifecycle.stop() + + +@pytest.mark.integration +class TestLoadDelegatePipeline: + """Full load() pipeline with delegate orchestration.""" + + def test_delegate_orchestration_wiring(self, fixture_path): + resolved = load(fixture_path("multi_agent_delegate.yaml")) + assert isinstance(resolved, ResolvedConfig) + assert "coordinator" in resolved.orchestrators + assert "researcher" in resolved.agents + assert "writer" in resolved.agents + assert resolved.entry is resolved.orchestrators["coordinator"] + + +@pytest.mark.integration +class TestLoadSwarmPipeline: + """Full load() pipeline with swarm orchestration.""" + + def test_swarm_orchestration_wiring(self, fixture_path): + resolved = load(fixture_path("swarm.yaml")) + assert isinstance(resolved, ResolvedConfig) + assert "team" in resolved.orchestrators + assert "analyst" in resolved.agents + assert "reporter" in resolved.agents + + +@pytest.mark.integration +class TestLoadGraphPipeline: + """Full load() pipeline with graph orchestration.""" + + def test_graph_orchestration_wiring(self, fixture_path): + resolved = load(fixture_path("graph.yaml")) + assert isinstance(resolved, ResolvedConfig) + assert "pipeline" in resolved.orchestrators + assert "collector" in resolved.agents + assert "analyzer" in resolved.agents + assert "summarizer" in resolved.agents + + +@pytest.mark.integration +class TestLoadNestedPipeline: + """Full load() pipeline with nested orchestrations.""" + + def test_nested_orchestration_wiring(self, fixture_path): + resolved = load(fixture_path("nested_orchestration.yaml")) + assert isinstance(resolved, ResolvedConfig) + assert "writing_team" in resolved.orchestrators + assert "full_pipeline" in resolved.orchestrators + assert resolved.entry is resolved.orchestrators["full_pipeline"] diff --git a/tests/integration/test_load_config.py b/tests/integration/test_load_config.py new file mode 100644 index 0000000..bce534a --- /dev/null +++ b/tests/integration/test_load_config.py @@ -0,0 +1,301 @@ +"""Integration tests for load_config() — full parse → validate → AppConfig pipeline.""" + +from __future__ import annotations + +import pytest + +from strands_compose.config import load_config +from strands_compose.config.schema import ( + AppConfig, + DelegateOrchestrationDef, + GraphOrchestrationDef, + SwarmOrchestrationDef, +) +from strands_compose.exceptions import SchemaValidationError + + +@pytest.mark.integration +class TestLoadConfigMinimal: + """Load the minimal fixture and verify the AppConfig.""" + + def test_loads_minimal_config(self, fixture_path): + config = load_config(fixture_path("minimal.yaml")) + assert isinstance(config, AppConfig) + assert "greeter" in config.agents + assert config.entry == "greeter" + + def test_minimal_agent_has_system_prompt(self, fixture_path): + config = load_config(fixture_path("minimal.yaml")) + assert config.agents["greeter"].system_prompt == "You are a helpful assistant." + + def test_minimal_has_no_orchestrations(self, fixture_path): + config = load_config(fixture_path("minimal.yaml")) + assert config.orchestrations == {} + + def test_minimal_has_no_models(self, fixture_path): + config = load_config(fixture_path("minimal.yaml")) + assert config.models == {} + + +@pytest.mark.integration +class TestLoadConfigWithModel: + """Load config with explicit model definition.""" + + def test_loads_model(self, fixture_path): + config = load_config(fixture_path("with_model.yaml")) + assert "bedrock_model" in config.models + assert config.models["bedrock_model"].provider == "bedrock" + + def test_agent_references_model(self, fixture_path): + config = load_config(fixture_path("with_model.yaml")) + assert config.agents["assistant"].model == "bedrock_model" + + +@pytest.mark.integration +class TestLoadConfigDelegate: + """Load delegate orchestration config.""" + + def test_loads_delegate_orchestration(self, fixture_path): + config = load_config(fixture_path("multi_agent_delegate.yaml")) + assert "coordinator" in config.orchestrations + orch = config.orchestrations["coordinator"] + assert isinstance(orch, DelegateOrchestrationDef) + + def test_delegate_has_connections(self, fixture_path): + config = load_config(fixture_path("multi_agent_delegate.yaml")) + orch = config.orchestrations["coordinator"] + assert isinstance(orch, DelegateOrchestrationDef) + assert len(orch.connections) == 1 + assert orch.connections[0].agent == "researcher" + + def test_delegate_entry_name(self, fixture_path): + config = load_config(fixture_path("multi_agent_delegate.yaml")) + orch = config.orchestrations["coordinator"] + assert isinstance(orch, DelegateOrchestrationDef) + assert orch.entry_name == "writer" + + def test_delegate_all_agents_defined(self, fixture_path): + config = load_config(fixture_path("multi_agent_delegate.yaml")) + assert "researcher" in config.agents + assert "writer" in config.agents + + +@pytest.mark.integration +class TestLoadConfigSwarm: + """Load swarm orchestration config.""" + + def test_loads_swarm_orchestration(self, fixture_path): + config = load_config(fixture_path("swarm.yaml")) + assert "team" in config.orchestrations + orch = config.orchestrations["team"] + assert isinstance(orch, SwarmOrchestrationDef) + + def test_swarm_has_agents_list(self, fixture_path): + config = load_config(fixture_path("swarm.yaml")) + orch = config.orchestrations["team"] + assert isinstance(orch, SwarmOrchestrationDef) + assert orch.agents == ["analyst", "reporter"] + + def test_swarm_entry_name(self, fixture_path): + config = load_config(fixture_path("swarm.yaml")) + orch = config.orchestrations["team"] + assert isinstance(orch, SwarmOrchestrationDef) + assert orch.entry_name == "analyst" + + def test_swarm_max_handoffs(self, fixture_path): + config = load_config(fixture_path("swarm.yaml")) + orch = config.orchestrations["team"] + assert isinstance(orch, SwarmOrchestrationDef) + assert orch.max_handoffs == 10 + + +@pytest.mark.integration +class TestLoadConfigGraph: + """Load graph orchestration config.""" + + def test_loads_graph_orchestration(self, fixture_path): + config = load_config(fixture_path("graph.yaml")) + assert "pipeline" in config.orchestrations + orch = config.orchestrations["pipeline"] + assert isinstance(orch, GraphOrchestrationDef) + + def test_graph_has_edges(self, fixture_path): + config = load_config(fixture_path("graph.yaml")) + orch = config.orchestrations["pipeline"] + assert isinstance(orch, GraphOrchestrationDef) + assert len(orch.edges) == 2 + + def test_graph_edge_structure(self, fixture_path): + config = load_config(fixture_path("graph.yaml")) + orch = config.orchestrations["pipeline"] + assert isinstance(orch, GraphOrchestrationDef) + edge = orch.edges[0] + assert edge.from_agent == "collector" + assert edge.to_agent == "analyzer" + + +@pytest.mark.integration +class TestLoadConfigNestedOrchestration: + """Load nested orchestration config.""" + + def test_loads_nested_orchestrations(self, fixture_path): + config = load_config(fixture_path("nested_orchestration.yaml")) + assert "writing_team" in config.orchestrations + assert "full_pipeline" in config.orchestrations + + def test_nested_entry_is_outer(self, fixture_path): + config = load_config(fixture_path("nested_orchestration.yaml")) + assert config.entry == "full_pipeline" + + def test_inner_orchestration_is_delegate(self, fixture_path): + config = load_config(fixture_path("nested_orchestration.yaml")) + inner = config.orchestrations["writing_team"] + assert isinstance(inner, DelegateOrchestrationDef) + assert inner.entry_name == "writer" + + def test_outer_references_inner(self, fixture_path): + config = load_config(fixture_path("nested_orchestration.yaml")) + outer = config.orchestrations["full_pipeline"] + assert isinstance(outer, DelegateOrchestrationDef) + assert any(c.agent == "writing_team" for c in outer.connections) + + +@pytest.mark.integration +class TestLoadConfigWithHooks: + """Load config with hook definitions.""" + + def test_loads_hooks(self, fixture_path): + config = load_config(fixture_path("with_hooks.yaml")) + agent = config.agents["assistant"] + assert len(agent.hooks) == 1 + + def test_hook_type_resolved(self, fixture_path): + config = load_config(fixture_path("with_hooks.yaml")) + hook = config.agents["assistant"].hooks[0] + assert not isinstance(hook, str) + assert hook.type == "strands_compose.hooks:MaxToolCallsGuard" + + def test_hook_params(self, fixture_path): + config = load_config(fixture_path("with_hooks.yaml")) + hook = config.agents["assistant"].hooks[0] + assert not isinstance(hook, str) + assert hook.params == {"max_calls": 5} + + +@pytest.mark.integration +class TestLoadConfigWithVars: + """Load config with variable interpolation.""" + + def test_vars_interpolated_in_model(self, fixture_path): + config = load_config(fixture_path("with_vars.yaml")) + assert config.models["main_model"].provider == "bedrock" + assert config.models["main_model"].model_id == "anthropic.claude-3-haiku-20240307-v1:0" + + +@pytest.mark.integration +class TestLoadConfigWithSessionManager: + """Load config with session manager.""" + + def test_session_manager_loaded(self, fixture_path): + config = load_config(fixture_path("with_session_manager.yaml")) + assert config.session_manager is not None + assert config.session_manager.provider == "file" + + def test_session_manager_params(self, fixture_path): + config = load_config(fixture_path("with_session_manager.yaml")) + assert config.session_manager is not None + assert config.session_manager.params["session_id"] == "test-session" + + +@pytest.mark.integration +class TestLoadConfigComplex: + """Load the complex full config with all features.""" + + def test_loads_complex_config(self, fixture_path): + config = load_config(fixture_path("complex_full.yaml")) + assert isinstance(config, AppConfig) + + def test_complex_has_two_models(self, fixture_path): + config = load_config(fixture_path("complex_full.yaml")) + assert "fast_model" in config.models + assert "smart_model" in config.models + + def test_complex_has_four_agents(self, fixture_path): + config = load_config(fixture_path("complex_full.yaml")) + assert len(config.agents) == 4 + + def test_complex_has_two_orchestrations(self, fixture_path): + config = load_config(fixture_path("complex_full.yaml")) + assert len(config.orchestrations) == 2 + + def test_complex_writing_team_is_delegate(self, fixture_path): + config = load_config(fixture_path("complex_full.yaml")) + wt = config.orchestrations["writing_team"] + assert isinstance(wt, DelegateOrchestrationDef) + + def test_complex_content_pipeline_is_graph(self, fixture_path): + config = load_config(fixture_path("complex_full.yaml")) + cp = config.orchestrations["content_pipeline"] + assert isinstance(cp, GraphOrchestrationDef) + + def test_complex_vars_interpolated(self, fixture_path): + config = load_config(fixture_path("complex_full.yaml")) + assert config.models["fast_model"].model_id == "anthropic.claude-3-haiku-20240307-v1:0" + + def test_complex_entry_is_graph(self, fixture_path): + config = load_config(fixture_path("complex_full.yaml")) + assert config.entry == "content_pipeline" + + def test_complex_agent_has_hooks(self, fixture_path): + config = load_config(fixture_path("complex_full.yaml")) + writer = config.agents["writer"] + assert len(writer.hooks) == 1 + + +@pytest.mark.integration +class TestLoadConfigMultipleFiles: + """Test loading and merging multiple config files.""" + + def test_merge_two_configs(self, tmp_path): + agents_file = tmp_path / "agents.yaml" + agents_file.write_text("agents:\n a:\n system_prompt: hello\nentry: a\n") + extra_file = tmp_path / "extra.yaml" + extra_file.write_text("agents:\n b:\n system_prompt: world\n") + config = load_config([str(agents_file), str(extra_file)]) + assert "a" in config.agents + assert "b" in config.agents + + def test_merge_duplicate_agents_raises(self, tmp_path): + f1 = tmp_path / "a.yaml" + f1.write_text("agents:\n dup:\n system_prompt: hi\nentry: dup\n") + f2 = tmp_path / "b.yaml" + f2.write_text("agents:\n dup:\n system_prompt: bye\n") + with pytest.raises(ValueError, match="Duplicate"): + load_config([str(f1), str(f2)]) + + +@pytest.mark.integration +class TestLoadConfigErrorCases: + """Test error handling in load_config.""" + + def test_missing_file_raises(self): + with pytest.raises(FileNotFoundError): + load_config("/nonexistent/path.yaml") + + def test_invalid_yaml_raises(self, tmp_path): + bad = tmp_path / "bad.yaml" + bad.write_text("{{not valid yaml") + with pytest.raises(Exception): + load_config(str(bad)) + + def test_missing_entry_raises(self, tmp_path): + f = tmp_path / "noentry.yaml" + f.write_text("agents:\n a:\n system_prompt: hi\nentry: missing\n") + with pytest.raises((ValueError, SchemaValidationError)): + load_config(str(f)) + + def test_empty_config_raises(self, tmp_path): + f = tmp_path / "empty.yaml" + f.write_text("{}") + with pytest.raises((ValueError, SchemaValidationError)): + load_config(str(f)) diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..fbafd4c --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""Unit tests for strands-compose.""" diff --git a/tests/unit/config/__init__.py b/tests/unit/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/config/loaders/__init__.py b/tests/unit/config/loaders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/config/loaders/conftest.py b/tests/unit/config/loaders/conftest.py new file mode 100644 index 0000000..2b53ecb --- /dev/null +++ b/tests/unit/config/loaders/conftest.py @@ -0,0 +1,131 @@ +"""Shared fixtures for config/loaders tests.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + + +@pytest.fixture +def write_config(tmp_path: Path): + """Write a YAML config file and return its path. + + Usage:: + + def test_something(write_config): + cfg = write_config("agents:\\n a:\\n system_prompt: hi\\nentry: a\\n") + config = load_config(cfg) + """ + + def _write(content: str, name: str = "config.yaml") -> Path: + f = tmp_path / name + f.write_text(content) + return f + + return _write + + +def _agent_yaml(name: str = "a", prompt: str = "hi", **extra: Any) -> str: + """Build a single agent YAML block. + + Args: + name: Agent name. + prompt: System prompt text. + **extra: Additional agent-level keys (e.g. model="m", mcp=["c"]). + + Returns: + YAML string for one agent entry (without the ``agents:`` header). + """ + lines = [f" {name}:", f" system_prompt: {prompt}"] + for key, val in extra.items(): + if isinstance(val, list): + lines.append(f" {key}:") + for item in val: + lines.append(f" - {item}") + else: + lines.append(f" {key}: {val}") + return "\n".join(lines) + + +def _agents_yaml(*agents: str, entry: str | None = None) -> str: + """Wrap one or more agent blocks into a full YAML config. + + Each argument is the output of :func:`_agent_yaml`. + ``entry`` defaults to the first agent name. + + Returns: + Complete YAML config string. + """ + body = "\n".join(agents) + if entry is None: + # Extract the first agent name from the first block + first_line = agents[0].strip().split("\n")[0] + entry = first_line.strip().rstrip(":") + return f"agents:\n{body}\nentry: {entry}\n" + + +def _minimal_yaml(prompt: str = "hi", entry: str = "a") -> str: + """Smallest valid config: one agent with a prompt.""" + return f"agents:\n {entry}:\n system_prompt: {prompt}\nentry: {entry}\n" + + +def _swarm_yaml( + agents: list[str], + entry_name: str | None = None, + orch_name: str = "main", +) -> str: + """Build a swarm orchestration block (no agents section — combine separately).""" + entry_name = entry_name or agents[0] + agent_list = "\n".join(f" - {a}" for a in agents) + return ( + f"orchestrations:\n" + f" {orch_name}:\n" + f" mode: swarm\n" + f" entry_name: {entry_name}\n" + f" agents:\n" + f"{agent_list}\n" + ) + + +def _graph_yaml( + edges: list[tuple[str, str]], + entry_name: str | None = None, + orch_name: str = "main", +) -> str: + """Build a graph orchestration block.""" + entry_name = entry_name or edges[0][0] + edge_lines = "\n".join(f" - from: {frm}\n to: {to}" for frm, to in edges) + return ( + f"orchestrations:\n" + f" {orch_name}:\n" + f" mode: graph\n" + f" entry_name: {entry_name}\n" + f" edges:\n" + f"{edge_lines}\n" + ) + + +def _delegate_yaml( + entry_name: str, + connections: list[tuple[str, str]], + orch_name: str = "main", +) -> str: + """Build a delegate orchestration block. + + Args: + entry_name: Name of the entry agent. + connections: ``[(child, description), ...]``. + """ + lines = [ + "orchestrations:", + f" {orch_name}:", + " mode: delegate", + f" entry_name: {entry_name}", + " connections:", + ] + for child, desc in connections: + lines.append(f" - agent: {child}") + lines.append(f" description: {desc}") + return "\n".join(lines) + "\n" diff --git a/tests/unit/config/loaders/test_helpers.py b/tests/unit/config/loaders/test_helpers.py new file mode 100644 index 0000000..c0465e1 --- /dev/null +++ b/tests/unit/config/loaders/test_helpers.py @@ -0,0 +1,219 @@ +"""Tests for config.loaders.helpers — sanitize, path rewriting, merge.""" + +from __future__ import annotations + +from pathlib import Path + +from strands_compose.config.loaders.helpers import ( + is_fs_spec, + make_absolute, + rewrite_relative_paths, + sanitize_name, +) + + +class TestSanitizeName: + """Unit tests for the sanitize_name helper.""" + + def test_spaces_to_underscores(self): + assert sanitize_name("Database Analyzer") == "Database_Analyzer" + + def test_special_chars_replaced(self): + assert sanitize_name("my.agent@v2") == "my_agent_v2" + + def test_consecutive_underscores_collapsed(self): + assert sanitize_name("a b") == "a_b" + + def test_leading_trailing_stripped(self): + assert sanitize_name(" hello ") == "hello" + + def test_hyphens_preserved(self): + assert sanitize_name("my-agent") == "my-agent" + + def test_valid_name_unchanged(self): + assert sanitize_name("valid_name") == "valid_name" + + def test_truncation_to_64(self): + long_name = "a" * 100 + assert len(sanitize_name(long_name)) == 64 + + def test_empty_result(self): + assert sanitize_name("...") == "" + + +class TestIsFsSpec: + def test_relative_file(self): + assert is_fs_spec("./tools.py") is True + + def test_relative_file_with_function(self): + assert is_fs_spec("./tools.py:my_func") is True + + def test_relative_directory(self): + assert is_fs_spec("./my_tools/") is True + + def test_module_spec(self): + assert is_fs_spec("my_package:my_func") is False + + def test_bare_module(self): + assert is_fs_spec("strands_tools") is False + + def test_absolute_path(self): + assert is_fs_spec("/abs/path/tools.py") is True + + def test_backslash_path(self): + assert is_fs_spec(".\\tools.py:func") is True + + +class TestMakeAbsolute: + def test_relative_file_becomes_absolute(self, tmp_path: Path): + result = make_absolute("./tools.py", tmp_path) + assert Path(result).is_absolute() + assert result == str((tmp_path / "tools.py").resolve()) + + def test_relative_file_with_function(self, tmp_path: Path): + result = make_absolute("./tools.py:func", tmp_path) + assert result == f"{(tmp_path / 'tools.py').resolve()}:func" + + def test_absolute_path_unchanged(self, tmp_path: Path): + result = make_absolute("/abs/tools.py", tmp_path) + assert result == "/abs/tools.py" + + def test_module_spec_unchanged(self, tmp_path: Path): + result = make_absolute("my_package:my_func", tmp_path) + assert result == "my_package:my_func" + + def test_relative_directory(self, tmp_path: Path): + result = make_absolute("./my_tools/", tmp_path) + assert Path(result).is_absolute() + + +class TestRewriteRelativePaths: + # ── agents.tools ────────────────────────────────────────────────────── + def test_tool_relative_file(self, tmp_path: Path): + raw: dict = {"agents": {"a": {"tools": ["./tools.py"]}}} + rewrite_relative_paths(raw, tmp_path) + assert Path(raw["agents"]["a"]["tools"][0]).is_absolute() + + def test_tool_relative_with_function(self, tmp_path: Path): + raw: dict = {"agents": {"a": {"tools": ["./tools.py:my_func"]}}} + rewrite_relative_paths(raw, tmp_path) + result = raw["agents"]["a"]["tools"][0] + assert ":my_func" in result + assert Path(result.rpartition(":")[0]).is_absolute() + + def test_tool_module_spec_unchanged(self, tmp_path: Path): + raw: dict = {"agents": {"a": {"tools": ["my_package:my_func"]}}} + rewrite_relative_paths(raw, tmp_path) + assert raw["agents"]["a"]["tools"][0] == "my_package:my_func" + + def test_tool_absolute_unchanged(self, tmp_path: Path): + raw: dict = {"agents": {"a": {"tools": ["/abs/tools.py"]}}} + rewrite_relative_paths(raw, tmp_path) + assert raw["agents"]["a"]["tools"][0] == "/abs/tools.py" + + # ── agents.hooks ────────────────────────────────────────────────────── + def test_hook_string_spec_rewritten(self, tmp_path: Path): + raw: dict = {"agents": {"a": {"hooks": ["./hooks.py:MyHook"]}}} + rewrite_relative_paths(raw, tmp_path) + result = raw["agents"]["a"]["hooks"][0] + assert Path(result.rpartition(":")[0]).is_absolute() + assert result.endswith(":MyHook") + + def test_hook_dict_type_rewritten(self, tmp_path: Path): + raw: dict = {"agents": {"a": {"hooks": [{"type": "./hooks.py:Guard", "params": {}}]}}} + rewrite_relative_paths(raw, tmp_path) + result = raw["agents"]["a"]["hooks"][0]["type"] + assert Path(result.rpartition(":")[0]).is_absolute() + assert result.endswith(":Guard") + + def test_hook_module_spec_unchanged(self, tmp_path: Path): + raw: dict = {"agents": {"a": {"hooks": ["strands_compose.hooks:StopGuard"]}}} + rewrite_relative_paths(raw, tmp_path) + assert raw["agents"]["a"]["hooks"][0] == "strands_compose.hooks:StopGuard" + + # ── agents.type ─────────────────────────────────────────────────────── + def test_agent_type_rewritten(self, tmp_path: Path): + raw: dict = {"agents": {"a": {"type": "./factory.py:CustomAgent"}}} + rewrite_relative_paths(raw, tmp_path) + result = raw["agents"]["a"]["type"] + assert Path(result.rpartition(":")[0]).is_absolute() + assert result.endswith(":CustomAgent") + + def test_agent_type_module_unchanged(self, tmp_path: Path): + raw: dict = {"agents": {"a": {"type": "my_pkg.agents:Custom"}}} + rewrite_relative_paths(raw, tmp_path) + assert raw["agents"]["a"]["type"] == "my_pkg.agents:Custom" + + # ── mcp_servers.type ────────────────────────────────────────────────── + def test_mcp_server_type_rewritten(self, tmp_path: Path): + raw: dict = {"mcp_servers": {"pg": {"type": "./server.py:create"}}} + rewrite_relative_paths(raw, tmp_path) + result = raw["mcp_servers"]["pg"]["type"] + assert Path(result.rpartition(":")[0]).is_absolute() + assert result.endswith(":create") + + def test_mcp_server_module_unchanged(self, tmp_path: Path): + raw: dict = {"mcp_servers": {"pg": {"type": "my_pkg:create"}}} + rewrite_relative_paths(raw, tmp_path) + assert raw["mcp_servers"]["pg"]["type"] == "my_pkg:create" + + # ── models.provider ─────────────────────────────────────────────────── + def test_model_provider_file_rewritten(self, tmp_path: Path): + raw: dict = {"models": {"custom": {"provider": "./models.py:MyModel", "model_id": "x"}}} + rewrite_relative_paths(raw, tmp_path) + result = raw["models"]["custom"]["provider"] + assert Path(result.rpartition(":")[0]).is_absolute() + assert result.endswith(":MyModel") + + def test_model_builtin_provider_unchanged(self, tmp_path: Path): + raw: dict = {"models": {"m": {"provider": "bedrock", "model_id": "x"}}} + rewrite_relative_paths(raw, tmp_path) + assert raw["models"]["m"]["provider"] == "bedrock" + + # ── session_manager.type ────────────────────────────────────────────── + def test_session_manager_type_rewritten(self, tmp_path: Path): + raw: dict = {"session_manager": {"type": "./session.py:CustomSM"}} + rewrite_relative_paths(raw, tmp_path) + result = raw["session_manager"]["type"] + assert Path(result.rpartition(":")[0]).is_absolute() + assert result.endswith(":CustomSM") + + def test_session_manager_module_type_unchanged(self, tmp_path: Path): + raw: dict = {"session_manager": {"type": "my_pkg:CustomSM"}} + rewrite_relative_paths(raw, tmp_path) + assert raw["session_manager"]["type"] == "my_pkg:CustomSM" + + # ── orchestrations hooks ────────────────────────────────────────────── + def test_orchestration_hooks_rewritten(self, tmp_path: Path): + raw: dict = { + "orchestrations": { + "main": { + "mode": "swarm", + "hooks": [ + "./hooks.py:Guard", + {"type": "./hooks.py:Logger", "params": {}}, + ], + } + } + } + rewrite_relative_paths(raw, tmp_path) + hooks = raw["orchestrations"]["main"]["hooks"] + hook_str = hooks[0] + assert isinstance(hook_str, str) + assert Path(hook_str.rpartition(":")[0]).is_absolute() + hook_dict = hooks[1] + assert isinstance(hook_dict, dict) + hook_type_val = hook_dict["type"] + assert isinstance(hook_type_val, str) + assert Path(hook_type_val.rpartition(":")[0]).is_absolute() + + # ── empty / missing sections ────────────────────────────────────────── + def test_empty_raw_is_noop(self, tmp_path: Path): + raw: dict = {} + rewrite_relative_paths(raw, tmp_path) + assert raw == {} + + def test_agent_without_tools_unchanged(self, tmp_path: Path): + raw: dict = {"agents": {"a": {"system_prompt": "hi"}}} + rewrite_relative_paths(raw, tmp_path) + assert raw == {"agents": {"a": {"system_prompt": "hi"}}} diff --git a/tests/unit/config/loaders/test_helpers_extended.py b/tests/unit/config/loaders/test_helpers_extended.py new file mode 100644 index 0000000..cd3eb48 --- /dev/null +++ b/tests/unit/config/loaders/test_helpers_extended.py @@ -0,0 +1,348 @@ +"""Tests for config.loaders.helpers — sanitize_collection_keys, update_references, +parse_single_source, merge_raw_configs. + +These are focused unit tests for functions that previously only had indirect +coverage via load_config() integration tests. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from strands_compose.config.loaders.helpers import ( + merge_raw_configs, + parse_single_source, + sanitize_collection_keys, + update_references, +) +from strands_compose.exceptions import ConfigurationError + +# ── sanitize_collection_keys ────────────────────────────────────────────── + + +class TestSanitizeCollectionKeys: + """Unit tests for sanitize_collection_keys().""" + + def test_valid_keys_unchanged(self): + raw: dict = {"agents": {"valid_name": {"system_prompt": "hi"}}} + sanitize_collection_keys(raw) + assert "valid_name" in raw["agents"] + + def test_spaces_to_underscores(self): + raw: dict = {"agents": {"My Agent": {"system_prompt": "hi"}}} + sanitize_collection_keys(raw) + assert "My_Agent" in raw["agents"] + assert "My Agent" not in raw["agents"] + + def test_special_chars_sanitized(self): + raw: dict = {"agents": {"my.agent@v2": {"system_prompt": "hi"}}} + sanitize_collection_keys(raw) + assert "my_agent_v2" in raw["agents"] + + def test_duplicate_after_sanitization_raises(self): + raw: dict = { + "agents": { + "my agent": {"system_prompt": "a"}, + "my_agent": {"system_prompt": "b"}, + } + } + with pytest.raises(ValueError, match="Duplicate name.*after sanitization"): + sanitize_collection_keys(raw) + + def test_empty_after_sanitization_raises(self): + raw: dict = {"agents": {"...": {"system_prompt": "hi"}}} + with pytest.raises(ValueError, match="empty after sanitization"): + sanitize_collection_keys(raw) + + def test_models_section_sanitized(self): + raw: dict = {"models": {"My Model": {"provider": "bedrock", "model_id": "x"}}} + sanitize_collection_keys(raw) + assert "My_Model" in raw["models"] + + def test_mcp_servers_section_sanitized(self): + raw: dict = {"mcp_servers": {"My Server": {"type": "stdio"}}} + sanitize_collection_keys(raw) + assert "My_Server" in raw["mcp_servers"] + + def test_non_dict_section_skipped(self): + raw: dict = {"agents": "not-a-dict"} + sanitize_collection_keys(raw) + assert raw["agents"] == "not-a-dict" + + def test_missing_section_ignored(self): + raw: dict = {"entry": "a"} + sanitize_collection_keys(raw) + assert raw == {"entry": "a"} + + def test_calls_update_references_when_renamed(self): + raw: dict = { + "agents": {"My Agent": {"system_prompt": "hi"}}, + "entry": "My Agent", + } + sanitize_collection_keys(raw) + assert raw["entry"] == "My_Agent" + + def test_preserves_definition_values(self): + raw: dict = {"agents": {"My Agent": {"system_prompt": "hello", "max_tool_calls": 5}}} + sanitize_collection_keys(raw) + assert raw["agents"]["My_Agent"]["system_prompt"] == "hello" + assert raw["agents"]["My_Agent"]["max_tool_calls"] == 5 + + +# ── update_references ───────────────────────────────────────────────────── + + +class TestUpdateReferences: + """Unit tests for update_references().""" + + def test_entry_reference_updated(self): + raw: dict = {"entry": "Old Name"} + update_references(raw, {"Old Name": "Old_Name"}) + assert raw["entry"] == "Old_Name" + + def test_entry_not_in_map_unchanged(self): + raw: dict = {"entry": "unchanged"} + update_references(raw, {"other": "other_renamed"}) + assert raw["entry"] == "unchanged" + + def test_agent_model_ref_updated(self): + raw: dict = {"agents": {"a": {"model": "My Model"}}} + update_references(raw, {"My Model": "My_Model"}) + assert raw["agents"]["a"]["model"] == "My_Model" + + def test_agent_mcp_refs_updated(self): + raw: dict = {"agents": {"a": {"mcp": ["Client One", "client_two"]}}} + update_references(raw, {"Client One": "Client_One"}) + assert raw["agents"]["a"]["mcp"] == ["Client_One", "client_two"] + + def test_mcp_client_server_ref_updated(self): + raw: dict = {"mcp_clients": {"c": {"server": "My Server"}}} + update_references(raw, {"My Server": "My_Server"}) + assert raw["mcp_clients"]["c"]["server"] == "My_Server" + + def test_delegate_connections_updated(self): + raw: dict = { + "orchestrations": { + "main": { + "mode": "delegate", + "entry_name": "Parent Agent", + "connections": [ + {"agent": "Child Agent", "description": "does stuff"}, + ], + } + } + } + update_references( + raw, + {"Parent Agent": "Parent_Agent", "Child Agent": "Child_Agent"}, + ) + orch: dict = raw["orchestrations"]["main"] + assert orch["entry_name"] == "Parent_Agent" + conns: list = orch["connections"] # type: ignore[assignment] + assert conns[0]["agent"] == "Child_Agent" + + def test_swarm_refs_updated(self): + raw: dict = { + "orchestrations": { + "main": { + "mode": "swarm", + "entry_name": "Agent A", + "agents": ["Agent A", "Agent B"], + } + } + } + update_references(raw, {"Agent A": "Agent_A", "Agent B": "Agent_B"}) + orch = raw["orchestrations"]["main"] + assert orch["entry_name"] == "Agent_A" + assert orch["agents"] == ["Agent_A", "Agent_B"] + + def test_graph_refs_updated(self): + raw: dict = { + "orchestrations": { + "main": { + "mode": "graph", + "entry_name": "Node A", + "edges": [ + {"from": "Node A", "to": "Node B"}, + ], + } + } + } + update_references(raw, {"Node A": "Node_A", "Node B": "Node_B"}) + orch = raw["orchestrations"]["main"] + assert orch["entry_name"] == "Node_A" + edges = orch["edges"] + assert edges[0]["from"] == "Node_A" # type: ignore[index] + assert edges[0]["to"] == "Node_B" # type: ignore[index] + + def test_non_dict_agent_def_skipped(self): + raw: dict = {"agents": {"a": "not-a-dict"}} + update_references(raw, {"x": "y"}) + assert raw["agents"]["a"] == "not-a-dict" + + def test_non_dict_client_def_skipped(self): + raw: dict = {"mcp_clients": {"c": "not-a-dict"}} + update_references(raw, {"x": "y"}) + assert raw["mcp_clients"]["c"] == "not-a-dict" + + def test_non_dict_orch_def_skipped(self): + raw: dict = {"orchestrations": {"main": "not-a-dict"}} + update_references(raw, {"x": "y"}) + assert raw["orchestrations"]["main"] == "not-a-dict" + + def test_empty_rename_map_noop(self): + raw: dict = {"entry": "a", "agents": {"a": {"model": "m"}}} + original = {"entry": "a", "agents": {"a": {"model": "m"}}} + update_references(raw, {}) + assert raw == original + + +# ── parse_single_source ─────────────────────────────────────────────────── + + +class TestParseSingleSource: + """Unit tests for parse_single_source().""" + + def test_parse_yaml_string(self): + raw = parse_single_source("agents:\n a:\n system_prompt: hi\nentry: a\n") + assert "agents" in raw + assert raw["agents"]["a"]["system_prompt"] == "hi" + + def test_parse_path_object(self, tmp_path: Path): + f = tmp_path / "config.yaml" + f.write_text("agents:\n a:\n system_prompt: hi\nentry: a\n") + raw = parse_single_source(f) + assert raw["agents"]["a"]["system_prompt"] == "hi" + + def test_parse_file_string(self, tmp_path: Path): + f = tmp_path / "config.yaml" + f.write_text("agents:\n a:\n system_prompt: hi\nentry: a\n") + raw = parse_single_source(str(f)) + assert raw["agents"]["a"]["system_prompt"] == "hi" + + def test_path_not_found_raises(self): + with pytest.raises(FileNotFoundError): + parse_single_source(Path("/nonexistent/config.yaml")) + + def test_string_looks_like_file_raises(self): + with pytest.raises(FileNotFoundError, match="Config file not found"): + parse_single_source("/nonexistent/config.yaml") + + def test_yaml_extension_string_not_found_raises(self): + with pytest.raises(FileNotFoundError, match="Config file not found"): + parse_single_source("missing.yaml") + + def test_non_dict_yaml_raises(self): + with pytest.raises(ValueError, match="YAML mapping"): + parse_single_source("just a string\n") + + def test_invalid_yaml_in_file_raises_configuration_error(self, tmp_path: Path): + f = tmp_path / "bad.yaml" + f.write_text("key: [unclosed\n") + with pytest.raises(ConfigurationError, match="Invalid YAML"): + parse_single_source(f) + + def test_invalid_yaml_inline_raises_configuration_error(self): + with pytest.raises(ConfigurationError, match="Invalid YAML"): + parse_single_source("key: [unclosed\n") + + def test_vars_block_stripped(self): + raw = parse_single_source( + "vars:\n X: hello\nagents:\n a:\n system_prompt: '${X}'\nentry: a\n" + ) + assert "vars" not in raw + assert raw["agents"]["a"]["system_prompt"] == "hello" + + def test_anchors_stripped(self): + raw = parse_single_source( + "x-base: &base\n system_prompt: shared\nagents:\n a:\n <<: *base\nentry: a\n" + ) + assert "x-base" not in raw + assert raw["agents"]["a"]["system_prompt"] == "shared" + + def test_relative_paths_rewritten(self, tmp_path: Path): + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n a:\n tools:\n - ./tools.py\n system_prompt: hi\nentry: a\n" + ) + raw = parse_single_source(f) + tool_path = raw["agents"]["a"]["tools"][0] + assert Path(tool_path).is_absolute() + + def test_env_var_interpolation(self, monkeypatch, tmp_path: Path): + monkeypatch.setenv("TEST_PROMPT_VALUE", "from-env") + raw = parse_single_source( + "agents:\n a:\n system_prompt: '${TEST_PROMPT_VALUE}'\nentry: a\n" + ) + assert raw["agents"]["a"]["system_prompt"] == "from-env" + + +# ── merge_raw_configs ───────────────────────────────────────────────────── + + +class TestMergeRawConfigs: + """Unit tests for merge_raw_configs().""" + + def test_merge_two_agent_configs(self): + c1 = {"agents": {"a": {"system_prompt": "hi"}}, "entry": "a"} + c2 = {"agents": {"b": {"system_prompt": "bye"}}} + merged = merge_raw_configs([c1, c2]) + assert "a" in merged["agents"] + assert "b" in merged["agents"] + + def test_duplicate_names_raises(self): + c1 = {"agents": {"dupe": {"system_prompt": "first"}}} + c2 = {"agents": {"dupe": {"system_prompt": "second"}}} + with pytest.raises(ValueError, match="Duplicate names in 'agents'"): + merge_raw_configs([c1, c2]) + + def test_singleton_last_wins(self): + c1 = {"agents": {"a": {}}, "entry": "a", "log_level": "DEBUG"} + c2 = {"agents": {"b": {}}, "log_level": "ERROR"} + merged = merge_raw_configs([c1, c2]) + assert merged["log_level"] == "ERROR" + + def test_empty_collections_removed(self): + c1 = {"agents": {"a": {}}, "entry": "a"} + merged = merge_raw_configs([c1]) + # models, mcp_servers, mcp_clients, orchestrations should not be present + assert "models" not in merged + assert "mcp_servers" not in merged + assert "mcp_clients" not in merged + assert "orchestrations" not in merged + + def test_merge_models_and_agents(self): + c1 = {"models": {"m": {"provider": "bedrock", "model_id": "x"}}} + c2 = {"agents": {"a": {}}, "entry": "a"} + merged = merge_raw_configs([c1, c2]) + assert "m" in merged["models"] + assert "a" in merged["agents"] + + def test_non_dict_section_ignored(self): + c1 = {"agents": "not-a-dict", "entry": "a"} + c2 = {"agents": {"b": {}}} + merged = merge_raw_configs([c1, c2]) + assert "b" in merged["agents"] + + def test_merge_three_configs(self): + c1 = {"agents": {"a": {}}} + c2 = {"agents": {"b": {}}} + c3 = {"agents": {"c": {}}, "entry": "a"} + merged = merge_raw_configs([c1, c2, c3]) + assert set(merged["agents"]) == {"a", "b", "c"} + + def test_merge_mcp_sections(self): + c1 = {"mcp_servers": {"s1": {"type": "stdio"}}} + c2 = {"mcp_clients": {"c1": {"server": "s1"}}} + merged = merge_raw_configs([c1, c2]) + assert "s1" in merged["mcp_servers"] + assert "c1" in merged["mcp_clients"] + + def test_merge_orchestrations(self): + c1 = {"orchestrations": {"orch1": {"mode": "swarm"}}} + c2 = {"orchestrations": {"orch2": {"mode": "graph"}}} + merged = merge_raw_configs([c1, c2]) + assert "orch1" in merged["orchestrations"] + assert "orch2" in merged["orchestrations"] diff --git a/tests/unit/config/loaders/test_load_session.py b/tests/unit/config/loaders/test_load_session.py new file mode 100644 index 0000000..6947d2c --- /dev/null +++ b/tests/unit/config/loaders/test_load_session.py @@ -0,0 +1,174 @@ +"""Tests for config.loaders.loaders — load_session.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from strands_compose.config.loaders.loaders import load_session +from strands_compose.config.resolvers.config import ResolvedConfig, ResolvedInfra +from strands_compose.config.schema import AgentDef, AppConfig, SwarmOrchestrationDef + + +class TestLoadSession: + """Unit tests for load_session() — the server-pattern session builder.""" + + @patch("strands_compose.config.loaders.loaders.resolve_orchestrations") + @patch("strands_compose.config.loaders.loaders.resolve_agents") + def test_returns_resolved_config(self, mock_resolve_agents, mock_resolve_orch): + mock_agent = MagicMock() + mock_resolve_agents.return_value = {"main": mock_agent} + mock_resolve_orch.return_value = {} + + config = AppConfig(agents={"main": AgentDef()}, entry="main") + infra = ResolvedInfra() + + result = load_session(config, infra) + + assert isinstance(result, ResolvedConfig) + assert result.agents == {"main": mock_agent} + assert result.entry is mock_agent + assert result.mcp_lifecycle is infra.mcp_lifecycle + + @patch("strands_compose.config.loaders.loaders.resolve_orchestrations") + @patch("strands_compose.config.loaders.loaders.resolve_agents") + def test_agents_receive_infra_models_and_clients(self, mock_resolve_agents, mock_resolve_orch): + mock_resolve_agents.return_value = {"main": MagicMock()} + mock_resolve_orch.return_value = {} + + infra = ResolvedInfra() + infra.models = {"gpt": MagicMock()} + infra.clients = {"pg": MagicMock()} + + config = AppConfig(agents={"main": AgentDef()}, entry="main") + load_session(config, infra) + + call_kwargs = mock_resolve_agents.call_args[1] + assert call_kwargs["models"] is infra.models + assert call_kwargs["mcp_clients"] is infra.clients + + @patch("strands_compose.config.loaders.loaders.resolve_orchestrations") + @patch("strands_compose.config.loaders.loaders.resolve_agents") + def test_uses_infra_session_manager_by_default(self, mock_resolve_agents, mock_resolve_orch): + mock_resolve_agents.return_value = {"main": MagicMock()} + mock_resolve_orch.return_value = {} + + infra = ResolvedInfra() + infra.session_manager = MagicMock() + + config = AppConfig(agents={"main": AgentDef()}, entry="main") + load_session(config, infra) + + call_kwargs = mock_resolve_agents.call_args[1] + assert call_kwargs["session_manager"] is infra.session_manager + + @patch("strands_compose.config.loaders.loaders.resolve_session_manager") + @patch("strands_compose.config.loaders.loaders.resolve_orchestrations") + @patch("strands_compose.config.loaders.loaders.resolve_agents") + def test_session_id_creates_fresh_session_manager( + self, + mock_resolve_agents, + mock_resolve_orch, + mock_resolve_sm, + ): + from strands_compose.config.schema import SessionManagerDef + + mock_resolve_agents.return_value = {"main": MagicMock()} + mock_resolve_orch.return_value = {} + fresh_sm = MagicMock() + mock_resolve_sm.return_value = fresh_sm + + config = AppConfig( + agents={"main": AgentDef()}, + entry="main", + session_manager=SessionManagerDef(provider="file"), + ) + infra = ResolvedInfra() + infra.session_manager = MagicMock() + + load_session(config, infra, session_id="abc-123") + + mock_resolve_sm.assert_called_once() + call_kwargs = mock_resolve_agents.call_args[1] + assert call_kwargs["session_manager"] is fresh_sm + + @patch("strands_compose.config.loaders.loaders.resolve_orchestrations") + @patch("strands_compose.config.loaders.loaders.resolve_agents") + def test_session_id_without_config_sm_uses_infra_sm( + self, mock_resolve_agents, mock_resolve_orch + ): + mock_resolve_agents.return_value = {"main": MagicMock()} + mock_resolve_orch.return_value = {} + + infra = ResolvedInfra() + infra.session_manager = MagicMock() + + config = AppConfig(agents={"main": AgentDef()}, entry="main") + load_session(config, infra, session_id="abc-123") + + call_kwargs = mock_resolve_agents.call_args[1] + assert call_kwargs["session_manager"] is infra.session_manager + + @patch("strands_compose.config.loaders.loaders.resolve_orchestrations") + @patch("strands_compose.config.loaders.loaders.resolve_agents") + def test_swarm_agents_excluded_from_session_manager( + self, mock_resolve_agents, mock_resolve_orch + ): + mock_resolve_agents.return_value = {"a": MagicMock(), "b": MagicMock()} + mock_resolve_orch.return_value = {} + + config = AppConfig( + agents={"a": AgentDef(), "b": AgentDef()}, + entry="a", + orchestrations={ + "sw": SwarmOrchestrationDef( + mode="swarm", + agents=["a", "b"], + entry_name="a", + ) + }, + ) + infra = ResolvedInfra() + load_session(config, infra) + + call_kwargs = mock_resolve_agents.call_args[1] + assert call_kwargs["swarm_agent_names"] == {"a", "b"} + + @patch("strands_compose.config.loaders.loaders.resolve_orchestrations") + @patch("strands_compose.config.loaders.loaders.resolve_agents") + def test_orchestrators_in_result(self, mock_resolve_agents, mock_resolve_orch): + mock_agent = MagicMock() + mock_orch = MagicMock() + mock_resolve_agents.return_value = {"main": mock_agent} + mock_resolve_orch.return_value = {"orch": mock_orch} + + config = AppConfig( + agents={"main": AgentDef()}, + orchestrations={ + "orch": SwarmOrchestrationDef(mode="swarm", agents=["main"], entry_name="main") + }, + entry="orch", + ) + infra = ResolvedInfra() + + result = load_session(config, infra) + + assert result.orchestrators == {"orch": mock_orch} + assert result.entry is mock_orch + + @patch("strands_compose.config.loaders.loaders.resolve_orchestrations") + @patch("strands_compose.config.loaders.loaders.resolve_agents") + def test_does_not_stop_infra_on_exception(self, mock_resolve_agents, mock_resolve_orch): + """P0-3: load_session must NOT stop shared MCP infrastructure on failure.""" + mock_resolve_agents.side_effect = RuntimeError("agent build failed") + + config = AppConfig(agents={"main": AgentDef()}, entry="main") + infra = ResolvedInfra() + infra.mcp_lifecycle = MagicMock() + + try: + load_session(config, infra) + except RuntimeError: + pass + + # The critical assertion: infra lifecycle must NOT be stopped + infra.mcp_lifecycle.stop.assert_not_called() diff --git a/tests/unit/config/loaders/test_loaders.py b/tests/unit/config/loaders/test_loaders.py new file mode 100644 index 0000000..52808af --- /dev/null +++ b/tests/unit/config/loaders/test_loaders.py @@ -0,0 +1,633 @@ +"""Tests for config.loaders.loaders — load, load_config, normalize.""" + +from __future__ import annotations + +import re +import textwrap +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from strands_compose.config.loaders import load, load_config +from strands_compose.config.loaders.loaders import normalize +from strands_compose.config.resolvers.config import ResolvedConfig +from strands_compose.config.schema import ( + DelegateOrchestrationDef, + GraphOrchestrationDef, + SwarmOrchestrationDef, +) +from strands_compose.exceptions import ConfigurationError + +from .conftest import ( + _agent_yaml, + _agents_yaml, + _delegate_yaml, + _minimal_yaml, + _swarm_yaml, +) + +# ── load() pipeline ────────────────────────────────────────────────────── + + +class TestLoad: + @patch("strands_compose.config.loaders.loaders.resolve_orchestrations") + @patch("strands_compose.config.loaders.loaders.resolve_agents") + @patch("strands_compose.config.loaders.loaders.resolve_infra") + def test_load_pipeline( + self, mock_resolve_infra, mock_resolve_agents, mock_resolve_orch, simple_config_yaml + ): + """load() resolves infra, creates agents, wires orchestration.""" + mock_infra = MagicMock() + mock_resolve_infra.return_value = mock_infra + mock_agent = MagicMock() + mock_resolve_agents.return_value = {"assistant": mock_agent} + mock_resolve_orch.return_value = {} + + result = load(simple_config_yaml) + + mock_resolve_infra.assert_called_once() + mock_infra.mcp_lifecycle.start.assert_called_once() + mock_resolve_agents.assert_called_once() + mock_resolve_orch.assert_called_once() + + assert isinstance(result, ResolvedConfig) + assert result.agents == {"assistant": mock_agent} + assert result.orchestrators == {} + assert result.entry is mock_agent + assert result.mcp_lifecycle is mock_infra.mcp_lifecycle + + +# ── normalize() ────────────────────────────────────────────────────────── + + +class TestNormalize: + def test_version_one_passes_through_unchanged(self): + result = normalize({"version": "1", "agents": {}, "entry": "a"}) + assert result["version"] == "1" + assert result["agents"] == {} + + def test_missing_version_defaults_to_one(self): + assert normalize({"agents": {}, "entry": "a"})["version"] == "1" + + def test_unknown_version_raises_value_error(self): + with pytest.raises(ValueError, match="schema version '99'"): + normalize({"version": "99", "agents": {}, "entry": "a"}) + + def test_does_not_mutate_input(self): + raw = {"agents": {}, "entry": "a"} + original = dict(raw) + normalize(raw) + assert raw == original + + +# ── load_config() ──────────────────────────────────────────────────────── + + +class TestLoadConfig: + def test_load_simple_config(self, simple_config_yaml): + config = load_config(simple_config_yaml) + assert "assistant" in config.agents + assert config.agents["assistant"].system_prompt == "You are a helpful assistant." + + def test_file_not_found(self): + with pytest.raises(FileNotFoundError): + load_config("/nonexistent/config.yaml") + + def test_invalid_yaml_type(self, write_config): + with pytest.raises(ValueError, match="YAML mapping"): + load_config(write_config("just a string\n", "bad.yaml")) + + def test_interpolation_in_config(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + vars: + PROMPT: hello + """) + + _agents_yaml(_agent_yaml(prompt="'${PROMPT}'")) + ) + assert load_config(cfg).agents["a"].system_prompt == "hello" + + def test_broken_model_ref_raises(self, write_config): + cfg = write_config(_agents_yaml(_agent_yaml(model="nonexistent"))) + with pytest.raises(ValueError, match=r"references model.*nonexistent"): + load_config(cfg) + + def test_broken_mcp_client_ref_raises(self, write_config): + cfg = write_config(_agents_yaml(_agent_yaml(mcp=["missing_client"]))) + with pytest.raises(ValueError, match=r"references MCP client.*missing_client"): + load_config(cfg) + + def test_broken_orchestration_delegate_ref(self, write_config): + cfg = write_config( + _agents_yaml(_agent_yaml("parent")) + + _delegate_yaml("parent", [("ghost", "does not exist")]) + ) + with pytest.raises(ValueError, match=r"ghost.*is not defined"): + load_config(cfg) + + def test_self_delegation_rejected(self, write_config): + cfg = write_config(_agents_yaml(_agent_yaml("a")) + _delegate_yaml("a", [("a", "self")])) + with pytest.raises(ValueError, match="delegates to itself"): + load_config(cfg) + + def test_strip_anchors(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + x-base: &base + system_prompt: shared + agents: + a: + <<: *base + entry: a + """) + ) + config = load_config(cfg) + assert "x-base" not in str(config.agents) + assert "a" in config.agents + + +# ── env variable interpolation ──────────────────────────────────────────── + + +class TestEnvVariableInterpolation: + def test_env_var_interpolation(self, write_config, monkeypatch): + monkeypatch.setenv("MY_PROMPT", "from-env") + cfg = write_config(_agents_yaml(_agent_yaml(prompt="'${MY_PROMPT}'"))) + assert load_config(cfg).agents["a"].system_prompt == "from-env" + + +class TestEnvBlock: + """Tests that env: block is ignored (feature removed).""" + + def test_no_env_block_is_fine(self, write_config): + config = load_config(write_config(_minimal_yaml())) + assert "a" in config.agents + + +# ── multi-source config merging ─────────────────────────────────────────── + + +class TestMultiSourceConfig: + """Tests for multi-file / multi-string config merging.""" + + def test_single_file_still_works(self, simple_config_yaml): + assert "assistant" in load_config(simple_config_yaml).agents + + def test_single_yaml_string(self): + config = load_config(_minimal_yaml("hello")) + assert config.agents["a"].system_prompt == "hello" + + def test_list_of_files(self, write_config): + f1 = write_config(_agents_yaml(_agent_yaml()), "agents.yaml") + f2 = write_config( + textwrap.dedent("""\ + models: + default: + provider: bedrock + model_id: claude + entry: a + """), + "models.yaml", + ) + config = load_config([f1, f2]) + assert "a" in config.agents + assert "default" in config.models + + def test_list_of_yaml_strings(self): + config = load_config( + [ + _agents_yaml(_agent_yaml()), + _agents_yaml(_agent_yaml("b", "bye"), entry="a"), + ] + ) + assert "a" in config.agents + assert "b" in config.agents + + def test_mixed_files_and_strings(self, write_config): + f = write_config(_agents_yaml(_agent_yaml(prompt="from file")), "agents.yaml") + config = load_config([f, _agents_yaml(_agent_yaml("b", "from string"), entry="a")]) + assert config.agents["a"].system_prompt == "from file" + assert config.agents["b"].system_prompt == "from string" + + def test_merge_all_collection_sections(self, write_config): + f1 = write_config( + textwrap.dedent("""\ + models: + m1: + provider: bedrock + model_id: claude + """) + + _agents_yaml(_agent_yaml("agent1", model="m1")), + "a.yaml", + ) + f2 = write_config( + textwrap.dedent("""\ + mcp_servers: + s1: + type: stdio + params: + command: echo + mcp_clients: + c1: + server: s1 + """), + "b.yaml", + ) + f3 = write_config( + _agents_yaml(_agent_yaml("agent2", model="m1", mcp=["c1"]), entry="agent1") + + _swarm_yaml(["agent1", "agent2"], orch_name="orch1") + + "entry: orch1\n", + "c.yaml", + ) + config = load_config([f1, f2, f3]) + assert set(config.models) == {"m1"} + assert set(config.agents) == {"agent1", "agent2"} + assert set(config.mcp_servers) == {"s1"} + assert set(config.mcp_clients) == {"c1"} + assert set(config.orchestrations) == {"orch1"} + + def test_duplicate_agent_names_raises(self, write_config): + f1 = write_config(_agents_yaml(_agent_yaml("dupe", "first")), "a.yaml") + f2 = write_config(_agents_yaml(_agent_yaml("dupe", "second")), "b.yaml") + with pytest.raises(ValueError, match=r"Duplicate names in 'agents'.*dupe"): + load_config([f1, f2]) + + def test_duplicate_model_names_raises(self, write_config): + f1 = write_config( + textwrap.dedent("""\ + models: + m: + provider: bedrock + model_id: a + """), + "a.yaml", + ) + f2 = write_config( + textwrap.dedent("""\ + models: + m: + provider: bedrock + model_id: b + """), + "b.yaml", + ) + with pytest.raises(ValueError, match=r"Duplicate names in 'models'.*m"): + load_config([f1, f2]) + + def test_duplicate_mcp_server_names_raises(self, write_config): + f1 = write_config( + textwrap.dedent("""\ + mcp_servers: + s: + type: stdio + """), + "a.yaml", + ) + f2 = write_config( + textwrap.dedent("""\ + mcp_servers: + s: + type: stdio + """), + "b.yaml", + ) + with pytest.raises(ValueError, match=r"Duplicate names in 'mcp_servers'.*s"): + load_config([f1, f2]) + + def test_singleton_last_wins(self, write_config): + f1 = write_config(_minimal_yaml() + "log_level: DEBUG\n", "a.yaml") + f2 = write_config( + _agents_yaml(_agent_yaml("b", "bye"), entry="a") + "log_level: ERROR\n", "b.yaml" + ) + config = load_config([f1, f2]) + assert config.log_level == "ERROR" + assert config.entry == "a" + + def test_per_source_vars_interpolation(self, write_config): + f1 = write_config( + textwrap.dedent("""\ + vars: + PROMPT: hello + """) + + _agents_yaml(_agent_yaml(prompt="'${PROMPT}'")), + "a.yaml", + ) + f2 = write_config( + textwrap.dedent("""\ + vars: + PROMPT: goodbye + """) + + _agents_yaml(_agent_yaml("b", "'${PROMPT}'"), entry="a"), + "b.yaml", + ) + config = load_config([f1, f2]) + assert config.agents["a"].system_prompt == "hello" + assert config.agents["b"].system_prompt == "goodbye" + + def test_per_source_anchors(self, write_config): + f1 = write_config( + textwrap.dedent("""\ + x-base: &base + system_prompt: shared + agents: + a: + <<: *base + """), + "a.yaml", + ) + f2 = write_config(_agents_yaml(_agent_yaml("b", "standalone"), entry="a"), "b.yaml") + config = load_config([f1, f2]) + assert config.agents["a"].system_prompt == "shared" + assert config.agents["b"].system_prompt == "standalone" + + def test_cross_ref_validation_after_merge(self, write_config): + f1 = write_config( + textwrap.dedent("""\ + models: + m: + provider: bedrock + model_id: claude + """), + "models.yaml", + ) + f2 = write_config(_agents_yaml(_agent_yaml(model="m")), "agents.yaml") + config = load_config([f1, f2]) + assert config.agents["a"].model == "m" + + def test_cross_ref_broken_after_merge_raises(self, write_config): + f1 = write_config(_agents_yaml(_agent_yaml(model="missing_model")), "agents.yaml") + with pytest.raises(ValueError, match="references model.*missing_model"): + load_config([f1]) + + def test_path_not_found_raises(self): + with pytest.raises(FileNotFoundError, match="Config file not found"): + load_config(Path("/nonexistent/config.yaml")) + + def test_invalid_yaml_string_raises(self): + with pytest.raises(ValueError, match="YAML mapping"): + load_config("just a plain string\n") + + +# ── name sanitization integration ───────────────────────────────────────── + + +class TestNameSanitization: + """Integration tests: names are sanitized during config loading.""" + + def test_spaces_replaced_with_underscores(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + agents: + 'Database Analyzer': + system_prompt: hi + entry: 'Database Analyzer' + """) + ) + config = load_config(cfg) + assert "Database_Analyzer" in config.agents + assert "Database Analyzer" not in config.agents + + def test_special_chars_sanitized(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + agents: + 'my.agent@v2': + system_prompt: hi + entry: 'my.agent@v2' + """) + ) + assert "my_agent_v2" in load_config(cfg).agents + + def test_valid_name_unchanged(self, write_config): + assert "valid_name" in load_config(write_config(_minimal_yaml(entry="valid_name"))).agents + + def test_entry_ref_updated(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + agents: + 'My Agent': + system_prompt: hi + entry: 'My Agent' + """) + ) + config = load_config(cfg) + assert config.entry == "My_Agent" + assert "My_Agent" in config.agents + + def test_model_ref_updated(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + models: + 'My Model': + provider: bedrock + model_id: claude + """) + + _agents_yaml(_agent_yaml(model="'My Model'")) + ) + config = load_config(cfg) + assert "My_Model" in config.models + assert config.agents["a"].model == "My_Model" + + def test_mcp_ref_updated(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + mcp_servers: + 'My Server': + type: stdio + params: + command: echo + mcp_clients: + 'My Client': + server: 'My Server' + """) + + _agents_yaml(_agent_yaml(mcp=["'My Client'"])) + ) + config = load_config(cfg) + assert "My_Server" in config.mcp_servers + assert "My_Client" in config.mcp_clients + assert config.mcp_clients["My_Client"].server == "My_Server" + assert config.agents["a"].mcp == ["My_Client"] + + def test_delegate_orchestration_refs_updated(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + agents: + 'Agent One': + system_prompt: hi + 'Agent Two': + system_prompt: bye + """) + + _delegate_yaml("'Agent One'", [("'Agent Two'", "helper")]) + + "entry: main\n" + ) + config = load_config(cfg) + assert "Agent_One" in config.agents + orch = config.orchestrations["main"] + assert isinstance(orch, DelegateOrchestrationDef) + assert orch.entry_name == "Agent_One" + assert orch.connections[0].agent == "Agent_Two" + + def test_swarm_refs_updated(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + agents: + 'Agent A': + system_prompt: hi + 'Agent B': + system_prompt: bye + orchestrations: + main: + mode: swarm + entry_name: 'Agent A' + agents: + - 'Agent A' + - 'Agent B' + entry: 'Agent A' + """) + ) + config = load_config(cfg) + orch = config.orchestrations["main"] + assert isinstance(orch, SwarmOrchestrationDef) + assert orch.agents == ["Agent_A", "Agent_B"] + + def test_graph_refs_updated(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + agents: + 'Agent A': + system_prompt: hi + 'Agent B': + system_prompt: bye + orchestrations: + main: + mode: graph + entry_name: 'Agent A' + edges: + - from: 'Agent A' + to: 'Agent B' + entry: 'Agent A' + """) + ) + config = load_config(cfg) + orch = config.orchestrations["main"] + assert isinstance(orch, GraphOrchestrationDef) + assert orch.edges[0].from_agent == "Agent_A" + assert orch.edges[0].to_agent == "Agent_B" + + def test_duplicate_after_sanitization_raises(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + agents: + 'my agent': + system_prompt: hi + my_agent: + system_prompt: bye + """) + ) + with pytest.raises(ValueError, match="Duplicate name.*after sanitization"): + load_config(cfg) + + def test_empty_after_sanitization_raises(self, write_config): + with pytest.raises(ValueError, match="empty after sanitization"): + load_config( + write_config( + textwrap.dedent("""\ + agents: + '...': + system_prompt: hi + """) + ) + ) + + +# ── ConfigurationError messages ─────────────────────────────────────────── + + +class TestConfigurationErrorMessages: + """ConfigurationError is raised for all config problems — never raw Pydantic dumps.""" + + def test_invalid_yaml_in_file_raises_configuration_error_with_path(self, write_config): + f = write_config("key: [\nunot closed\n", "bad.yaml") + with pytest.raises(ConfigurationError, match=re.escape(str(f))): + load_config(f) + + def test_invalid_yaml_in_inline_string_raises_configuration_error(self): + with pytest.raises(ConfigurationError, match="Invalid YAML"): + load_config("key: [unclosed\n") + + def test_pydantic_validation_error_raises_configuration_error(self, write_config): + cfg = write_config(_minimal_yaml() + "log_level: 42\n") + with pytest.raises(ConfigurationError, match=r"log_level"): + load_config(cfg) + + def test_pydantic_error_message_has_no_pydantic_dump(self, write_config): + cfg = write_config(_minimal_yaml() + "log_level: 42\n") + with pytest.raises(ConfigurationError) as exc_info: + load_config(cfg) + msg = str(exc_info.value) + assert "For further information" not in msg + assert "Check your YAML configuration file." in msg + + def test_unknown_model_ref_raises_configuration_error_listing_models(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + models: + gpt4: + provider: openai + model_id: gpt-4 + """) + + _agents_yaml(_agent_yaml(model="TYPO")) + ) + with pytest.raises(ConfigurationError, match=r"TYPO"): + load_config(cfg) + + def test_unknown_mcp_client_ref_raises_configuration_error_listing_clients(self, write_config): + cfg = write_config(_agents_yaml(_agent_yaml(mcp=["GHOST_CLIENT"]))) + with pytest.raises(ConfigurationError, match=r"GHOST_CLIENT"): + load_config(cfg) + + def test_configuration_error_is_subclass_of_value_error(self): + assert issubclass(ConfigurationError, ValueError) + + +# ── path rewriting integration ──────────────────────────────────────────── + + +class TestLoadConfigPathRewriting: + """Integration tests: load_config rewrites relative filesystem paths.""" + + def test_load_config_rewrites_tool_paths(self, tmp_path): + tools_file = tmp_path / "tools.py" + tools_file.write_text("") + config_file = tmp_path / "config.yaml" + config_file.write_text(_agents_yaml(_agent_yaml(tools=["./tools.py"]), entry="a")) + config = load_config(config_file) + tool_spec = config.agents["a"].tools[0] + assert Path(tool_spec).is_absolute() + assert Path(tool_spec) == tools_file.resolve() + + def test_load_config_rewrites_tool_with_function(self, write_config): + cfg = write_config(_agents_yaml(_agent_yaml(tools=["./tools.py:my_func"]))) + config = load_config(cfg) + abs_part, _, func_part = config.agents["a"].tools[0].rpartition(":") + assert Path(abs_part).is_absolute() + assert func_part == "my_func" + + def test_load_config_rewrites_hook_type(self, write_config): + cfg = write_config( + textwrap.dedent("""\ + agents: + analyst: + hooks: + - type: ./hooks.py:MyGuard + system_prompt: hi + entry: analyst + """) + ) + config = load_config(cfg) + hook = config.agents["analyst"].hooks[0] + assert not isinstance(hook, str) + hook_type = hook.type + assert Path(hook_type.rpartition(":")[0]).is_absolute() + assert hook_type.endswith(":MyGuard") diff --git a/tests/unit/config/loaders/test_validators.py b/tests/unit/config/loaders/test_validators.py new file mode 100644 index 0000000..e708f29 --- /dev/null +++ b/tests/unit/config/loaders/test_validators.py @@ -0,0 +1,227 @@ +"""Tests for config.loaders.validators — _validate_references and orchestration checks.""" + +from __future__ import annotations + +import pytest + +from strands_compose.config.loaders import load_config + + +class TestValidateMCPClientServerRef: + def test_mcp_client_broken_server_ref(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text( + "mcp_clients:\n" + " my_client:\n" + " server: nonexistent_server\n" + "agents:\n" + " a:\n" + " system_prompt: hi\n" + "entry: a\n" + ) + with pytest.raises(ValueError, match=r"references server.*nonexistent_server"): + load_config(f) + + +class TestValidateOrchestrationDelegateParent: + def test_delegate_entry_not_in_agents(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n" + " child:\n" + " system_prompt: hi\n" + "orchestrations:\n" + " main:\n" + " mode: delegate\n" + " entry_name: ghost_parent\n" + " connections:\n" + " - agent: child\n" + " description: test\n" + "entry: main\n" + ) + with pytest.raises(ValueError, match=r"ghost_parent.*is not defined"): + load_config(f) + + +class TestValidateSwarmOrchestration: + def test_swarm_invalid_agent_ref(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n" + " a:\n" + " system_prompt: hi\n" + "orchestrations:\n" + " main:\n" + " mode: swarm\n" + " entry_name: a\n" + " agents:\n" + " - a\n" + " - ghost\n" + "entry: a\n" + ) + with pytest.raises(ValueError, match=r"Swarm agent.*ghost.*is not defined"): + load_config(f) + + def test_swarm_valid(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n" + " a:\n" + " system_prompt: hi\n" + " b:\n" + " system_prompt: hi\n" + "orchestrations:\n" + " main:\n" + " mode: swarm\n" + " entry_name: a\n" + " agents:\n" + " - a\n" + " - b\n" + "entry: a\n" + ) + config = load_config(f) + assert "main" in config.orchestrations + assert config.orchestrations["main"].mode == "swarm" + + +class TestValidateGraphOrchestration: + def test_graph_invalid_from_agent(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n" + " a:\n" + " system_prompt: hi\n" + "orchestrations:\n" + " main:\n" + " mode: graph\n" + " entry_name: a\n" + " edges:\n" + " - from: ghost\n" + " to: a\n" + "entry: a\n" + ) + with pytest.raises(ValueError, match=r"Graph edge source.*ghost.*is not defined"): + load_config(f) + + def test_graph_invalid_to_agent(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n" + " a:\n" + " system_prompt: hi\n" + "orchestrations:\n" + " main:\n" + " mode: graph\n" + " entry_name: a\n" + " edges:\n" + " - from: a\n" + " to: ghost\n" + "entry: a\n" + ) + with pytest.raises(ValueError, match=r"Graph edge target.*ghost.*is not defined"): + load_config(f) + + def test_graph_valid(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n" + " a:\n" + " system_prompt: hi\n" + " b:\n" + " system_prompt: hi\n" + "orchestrations:\n" + " main:\n" + " mode: graph\n" + " entry_name: a\n" + " edges:\n" + " - from: a\n" + " to: b\n" + "entry: a\n" + ) + config = load_config(f) + assert "main" in config.orchestrations + assert config.orchestrations["main"].mode == "graph" + + +class TestNamedOrchestrationsValidation: + """Tests for named orchestrations: validation in _validate_references.""" + + def test_named_orch_valid_refs(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n" + " a:\n" + " system_prompt: hi\n" + " b:\n" + " system_prompt: hi\n" + "orchestrations:\n" + " my_swarm:\n" + " mode: swarm\n" + " entry_name: a\n" + " agents: [a, b]\n" + "entry: my_swarm\n" + ) + config = load_config(f) + assert "my_swarm" in config.orchestrations + + def test_named_orch_cross_reference(self, tmp_path): + """Named orchestrations can reference other orchestrations.""" + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n" + " a:\n" + " system_prompt: hi\n" + " b:\n" + " system_prompt: hi\n" + " reviewer:\n" + " system_prompt: hi\n" + "orchestrations:\n" + " team:\n" + " mode: swarm\n" + " entry_name: a\n" + " agents: [a, b]\n" + " pipeline:\n" + " mode: graph\n" + " entry_name: team\n" + " edges:\n" + " - from: team\n" + " to: reviewer\n" + "entry: pipeline\n" + ) + config = load_config(f) + assert "team" in config.orchestrations + assert "pipeline" in config.orchestrations + + def test_named_orch_invalid_ref_raises(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n" + " a:\n" + " system_prompt: hi\n" + "orchestrations:\n" + " my_swarm:\n" + " mode: swarm\n" + " entry_name: a\n" + " agents: [a, ghost]\n" + "entry: my_swarm\n" + ) + with pytest.raises(ValueError, match=r"ghost.*is not defined"): + load_config(f) + + def test_named_orch_with_entry(self, tmp_path): + f = tmp_path / "config.yaml" + f.write_text( + "agents:\n" + " a:\n" + " system_prompt: hi\n" + " b:\n" + " system_prompt: hi\n" + "orchestrations:\n" + " my_swarm:\n" + " mode: swarm\n" + " entry_name: a\n" + " agents: [a, b]\n" + "entry: my_swarm\n" + ) + config = load_config(f) + assert config.entry == "my_swarm" diff --git a/tests/unit/config/resolvers/__init__.py b/tests/unit/config/resolvers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/config/resolvers/orchestrations/__init__.py b/tests/unit/config/resolvers/orchestrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/config/resolvers/orchestrations/test_builders.py b/tests/unit/config/resolvers/orchestrations/test_builders.py new file mode 100644 index 0000000..95fa25f --- /dev/null +++ b/tests/unit/config/resolvers/orchestrations/test_builders.py @@ -0,0 +1,328 @@ +"""Tests for orchestrations.builders — build_delegate, build_swarm, build_graph, OrchestrationBuilder.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from strands import Agent as _Agent +from strands.multiagent.base import MultiAgentBase + +from strands_compose.config.resolvers.orchestrations.builders import ( + OrchestrationBuilder, + build_delegate, + build_graph, + build_swarm, +) +from strands_compose.config.schema import ( + AgentDef, + DelegateConnectionDef, + DelegateOrchestrationDef, + GraphEdgeDef, + GraphOrchestrationDef, + SwarmOrchestrationDef, +) +from strands_compose.exceptions import ConfigurationError + +# --------------------------------------------------------------------------- +# build_delegate +# --------------------------------------------------------------------------- + + +class TestBuildDelegate: + """build_delegate forks a new agent from entry blueprint with delegate tools.""" + + @patch("strands_compose.config.resolvers.orchestrations.builders.build_agent_from_def") + def test_creates_new_agent_from_blueprint(self, mock_build: MagicMock) -> None: + """build_delegate constructs a NEW agent via build_agent_from_def.""" + new_agent = MagicMock(spec=_Agent) + mock_build.return_value = new_agent + child = MagicMock(spec=_Agent) + child.agent_id = "child" + nodes: dict = {"parent": MagicMock(spec=_Agent), "child": child} + agent_defs: dict = {"parent": AgentDef(system_prompt="I'm the parent")} + config = DelegateOrchestrationDef( + entry_name="parent", + connections=[DelegateConnectionDef(agent="child", description="do work")], + ) + + result = build_delegate("orch", config, nodes, "parent", agent_defs, {}, {}) + + # Returns the newly built agent, NOT the original parent. + assert result is new_agent + mock_build.assert_called_once() + call_kwargs = mock_build.call_args + assert call_kwargs.kwargs["name"] == "orch" + assert call_kwargs.kwargs["agent_def"] is agent_defs["parent"] + assert len(call_kwargs.kwargs["extra_tools"]) == 1 + + @patch("strands_compose.config.resolvers.orchestrations.builders.build_agent_from_def") + def test_delegate_accepts_multi_agent_as_child(self, mock_build: MagicMock) -> None: + """build_delegate can wrap a MultiAgentBase (built orchestration) as a tool.""" + new_agent = MagicMock(spec=_Agent) + mock_build.return_value = new_agent + multi_agent = MagicMock(spec=MultiAgentBase) + multi_agent.id = "my_swarm" + nodes: dict = {"parent": MagicMock(spec=_Agent), "my_swarm": multi_agent} + agent_defs: dict = {"parent": AgentDef(system_prompt="I coordinate")} + config = DelegateOrchestrationDef( + entry_name="parent", + connections=[DelegateConnectionDef(agent="my_swarm", description="use swarm")], + ) + + result = build_delegate("orch", config, nodes, "parent", agent_defs, {}, {}) + + assert result is new_agent + + def test_entry_not_in_agent_defs_raises(self) -> None: + """Entry name not in agent_defs raises ConfigurationError.""" + nodes: dict = {"parent": MagicMock(spec=_Agent)} + config = DelegateOrchestrationDef( + entry_name="parent", + connections=[DelegateConnectionDef(agent="child", description="x")], + ) + with pytest.raises(ConfigurationError, match="must be a declared agent"): + build_delegate("orch", config, nodes, "parent", {}, {}, {}) + + @patch("strands_compose.config.resolvers.orchestrations.builders.build_agent_from_def") + def test_no_overrides_passes_original_def(self, mock_build: MagicMock) -> None: + """When no overrides are set the original AgentDef object is passed through.""" + mock_build.return_value = MagicMock(spec=_Agent) + entry_def = AgentDef(system_prompt="original", agent_kwargs={"max_tool_calls": 5}) + child = MagicMock(spec=_Agent) + child.agent_id = "child" + nodes: dict = {"parent": MagicMock(spec=_Agent), "child": child} + config = DelegateOrchestrationDef( + entry_name="parent", + connections=[DelegateConnectionDef(agent="child", description="x")], + ) + + build_delegate("orch", config, nodes, "parent", {"parent": entry_def}, {}, {}) + + # No copy should be made — same object passed + assert mock_build.call_args.kwargs["agent_def"] is entry_def + + @patch("strands_compose.config.resolvers.orchestrations.builders.build_agent_from_def") + def test_agent_kwargs_merged(self, mock_build: MagicMock) -> None: + """agent_kwargs are merged: orchestration values override, base keys are inherited.""" + mock_build.return_value = MagicMock(spec=_Agent) + entry_def = AgentDef( + agent_kwargs={"max_tool_calls": 5, "trace_attributes": {"env": "test"}} + ) + child = MagicMock(spec=_Agent) + child.agent_id = "child" + nodes: dict = {"parent": MagicMock(spec=_Agent), "child": child} + config = DelegateOrchestrationDef( + entry_name="parent", + connections=[DelegateConnectionDef(agent="child", description="x")], + agent_kwargs={"max_tool_calls": 50}, # override + ) + + build_delegate("orch", config, nodes, "parent", {"parent": entry_def}, {}, {}) + + passed_def = mock_build.call_args.kwargs["agent_def"] + # orchestration wins on conflict + assert passed_def.agent_kwargs["max_tool_calls"] == 50 + # base key inherited + assert passed_def.agent_kwargs["trace_attributes"] == {"env": "test"} + # original unmodified + assert entry_def.agent_kwargs["max_tool_calls"] == 5 + + +# --------------------------------------------------------------------------- +# build_swarm +# --------------------------------------------------------------------------- + + +class TestBuildSwarm: + """build_swarm creates a Swarm from config and agent nodes.""" + + @patch("strands_compose.config.resolvers.orchestrations.builders.Swarm") + def test_creates_swarm_with_entry_point(self, mock_swarm: MagicMock) -> None: + """build_swarm instantiates Swarm with the correct entry_point agent.""" + a1 = MagicMock(spec=_Agent) + a2 = MagicMock(spec=_Agent) + nodes: dict = {"a1": a1, "a2": a2} + config = SwarmOrchestrationDef(entry_name="a1", agents=["a1", "a2"]) + + build_swarm("test_swarm", config, nodes, "a1") + + mock_swarm.assert_called_once() + assert mock_swarm.call_args.kwargs["entry_point"] is a1 + + def test_raises_on_non_agent_node(self) -> None: + """Swarm nodes must be plain Agent instances; other types raise ConfigurationError.""" + agent = MagicMock(spec=_Agent) + non_agent = MagicMock() + non_agent.__class__.__name__ = "Graph" + nodes: dict = {"a1": agent, "graph1": non_agent} + config = SwarmOrchestrationDef(entry_name="a1", agents=["a1", "graph1"]) + + with pytest.raises(ConfigurationError, match="must be a plain Agent"): + build_swarm("test_swarm", config, nodes, "a1") + + +# --------------------------------------------------------------------------- +# build_graph +# --------------------------------------------------------------------------- + + +class TestBuildGraph: + """build_graph wires agents into a Graph via GraphBuilder.""" + + @patch("strands_compose.config.resolvers.orchestrations.builders.GraphBuilder") + def test_creates_graph_with_nodes_and_edges(self, mock_builder_cls: MagicMock) -> None: + """build_graph adds all agent nodes, the configured edge, and calls build().""" + builder = MagicMock() + mock_builder_cls.return_value = builder + a1, a2 = MagicMock(), MagicMock() + nodes: dict = {"a1": a1, "a2": a2} + config = GraphOrchestrationDef( + entry_name="a1", + edges=[GraphEdgeDef(from_agent="a1", to_agent="a2")], # type: ignore[call-arg] + ) + + build_graph("test_graph", config, nodes, "a1") + + builder.add_node.assert_called() + builder.add_edge.assert_called_once_with("a1", "a2", condition=None) + builder.set_entry_point.assert_called_once_with("a1") + builder.build.assert_called_once() + + @patch("strands_compose.config.resolvers.orchestrations.builders.GraphBuilder") + def test_graph_accepts_orchestration_node(self, mock_builder_cls: MagicMock) -> None: + """Graph supports MultiAgentBase (nested orchestration) as a node.""" + builder = MagicMock() + mock_builder_cls.return_value = builder + agent = MagicMock() + multi_agent = MagicMock() + nodes: dict = {"agent1": agent, "nested_swarm": multi_agent} + config = GraphOrchestrationDef( + entry_name="agent1", + edges=[ + GraphEdgeDef(from_agent="agent1", to_agent="nested_swarm"), # type: ignore[call-arg] + ], + ) + + build_graph("test_graph", config, nodes, "agent1") + + builder.add_node.assert_any_call(multi_agent, node_id="nested_swarm") + + +# --------------------------------------------------------------------------- +# OrchestrationBuilder — dispatch and integration +# --------------------------------------------------------------------------- + + +class TestOrchestrationBuilderDispatch: + """OrchestrationBuilder raises on unknown config types.""" + + def test_unknown_config_type_raises_configuration_error(self) -> None: + """A config type not matching any known orchestration raises ConfigurationError.""" + a1 = MagicMock(spec=_Agent) + unknown_cfg = MagicMock() + unknown_cfg.session_manager = None + unknown_cfg.entry_name = "a1" + configs = {"bad": unknown_cfg} + + with pytest.raises(ConfigurationError, match="Unknown orchestration config type"): + OrchestrationBuilder(configs, {"a1": a1}, {}, {}, {}).build_all() # type: ignore[arg-type] + + +class TestOrchestrationBuilder: + """OrchestrationBuilder integration: entry resolution, ordering, node pool growth.""" + + @patch("strands_compose.config.resolvers.orchestrations.builders.build_agent_from_def") + def test_delegate_returns_new_agent(self, mock_build: MagicMock) -> None: + """Delegate produces a new agent — not the original entry agent.""" + original = MagicMock(spec=_Agent) + new_agent = MagicMock(spec=_Agent) + mock_build.return_value = new_agent + child = MagicMock(spec=_Agent) + child.agent_id = "child" + agents: dict = {"root": original, "child": child} + agent_defs: dict = {"root": AgentDef(system_prompt="I coordinate"), "child": AgentDef()} + configs = { + "orch": DelegateOrchestrationDef( + entry_name="root", + connections=[DelegateConnectionDef(agent="child", description="delegate")], + ), + } + + built = OrchestrationBuilder(configs, agents, agent_defs, {}, {}).build_all() # type: ignore[arg-type] + + assert built["orch"] is new_agent + assert built["orch"] is not original + + @patch("strands_compose.config.resolvers.orchestrations.builders.Swarm") + def test_builds_swarm_in_topological_order(self, mock_swarm_cls: MagicMock) -> None: + """OrchestrationBuilder correctly builds a single swarm.""" + a1 = MagicMock(spec=_Agent) + a2 = MagicMock(spec=_Agent) + agents = {"a1": a1, "a2": a2} + mock_swarm_cls.return_value = MagicMock() + configs = { + "my_swarm": SwarmOrchestrationDef(entry_name="a1", agents=["a1", "a2"]), + } + + built = OrchestrationBuilder(configs, agents, {}, {}, {}).build_all() # type: ignore[arg-type] + + assert "my_swarm" in built + mock_swarm_cls.assert_called_once() + + @patch("strands_compose.config.resolvers.orchestrations.builders.GraphBuilder") + @patch("strands_compose.config.resolvers.orchestrations.builders.Swarm") + def test_node_pool_grows_for_downstream_orchestrations( + self, mock_swarm_cls: MagicMock, mock_builder_cls: MagicMock + ) -> None: + """A graph referencing a named swarm receives the built swarm as a node.""" + a1 = MagicMock(spec=_Agent) + a2 = MagicMock(spec=_Agent) + reviewer = MagicMock(spec=_Agent) + agents = {"a1": a1, "a2": a2, "reviewer": reviewer} + mock_swarm = MagicMock() + mock_swarm_cls.return_value = mock_swarm + builder = MagicMock() + mock_builder_cls.return_value = builder + configs = { + "research_swarm": SwarmOrchestrationDef(entry_name="a1", agents=["a1", "a2"]), + "pipeline": GraphOrchestrationDef( + entry_name="research_swarm", + edges=[ + GraphEdgeDef(from_agent="research_swarm", to_agent="reviewer"), # type: ignore[call-arg] + ], + ), + } + + built = OrchestrationBuilder(configs, agents, {}, {}, {}).build_all() # type: ignore[arg-type] + + assert "research_swarm" in built + assert "pipeline" in built + builder.add_node.assert_any_call(mock_swarm, node_id="research_swarm") + + @patch("strands_compose.config.resolvers.orchestrations.builders.Swarm") + def test_session_manager_forwarded_to_swarm(self, mock_swarm_cls: MagicMock) -> None: + """A session manager passed to OrchestrationBuilder is forwarded to Swarm.""" + a1 = MagicMock(spec=_Agent) + a2 = MagicMock(spec=_Agent) + agents = {"a1": a1, "a2": a2} + sm = MagicMock() + mock_swarm_cls.return_value = MagicMock() + configs = { + "my_swarm": SwarmOrchestrationDef(entry_name="a1", agents=["a1", "a2"]), + } + + OrchestrationBuilder(configs, agents, {}, {}, {}, sm).build_all() # type: ignore[arg-type] + + assert mock_swarm_cls.call_args.kwargs["session_manager"] is sm + + def test_invalid_entry_name_raises_configuration_error(self) -> None: + """entry_name not in the node pool raises ConfigurationError.""" + a1 = MagicMock(spec=_Agent) + agents = {"a1": a1} + configs = { + "my_swarm": SwarmOrchestrationDef(entry_name="nonexistent", agents=["a1"]), + } + + with pytest.raises(ConfigurationError, match="entry_name 'nonexistent' is not defined"): + OrchestrationBuilder(configs, agents, {}, {}, {}).build_all() # type: ignore[arg-type] diff --git a/tests/unit/config/resolvers/orchestrations/test_planner.py b/tests/unit/config/resolvers/orchestrations/test_planner.py new file mode 100644 index 0000000..e2d3c6e --- /dev/null +++ b/tests/unit/config/resolvers/orchestrations/test_planner.py @@ -0,0 +1,115 @@ +"""Tests for orchestrations.planner — collect_node_refs and topological_sort.""" + +from __future__ import annotations + +import pytest + +from strands_compose.config.resolvers.orchestrations.planner import ( + collect_node_refs, + topological_sort, +) +from strands_compose.config.schema import ( + DelegateConnectionDef, + DelegateOrchestrationDef, + GraphEdgeDef, + GraphOrchestrationDef, + SwarmOrchestrationDef, +) +from strands_compose.exceptions import ConfigurationError + + +class TestCollectNodeRefs: + """collect_node_refs extracts all node names referenced by an orchestration.""" + + def test_delegate_collects_entry_and_children(self) -> None: + """Delegate connections include entry_name and all child agent names.""" + config = DelegateOrchestrationDef( + entry_name="parent", + connections=[ + DelegateConnectionDef(agent="child1", description="c1"), + DelegateConnectionDef(agent="child2", description="c2"), + ], + ) + assert collect_node_refs(config) == {"parent", "child1", "child2"} + + def test_swarm_collects_all_agents(self) -> None: + """Swarm refs include all listed agent names.""" + config = SwarmOrchestrationDef(entry_name="a", agents=["a", "b", "c"]) + assert collect_node_refs(config) == {"a", "b", "c"} + + def test_graph_collects_edge_endpoints(self) -> None: + """Graph refs include both from_agent and to_agent of every edge.""" + config = GraphOrchestrationDef( + entry_name="a", + edges=[ + GraphEdgeDef(from_agent="a", to_agent="b"), # type: ignore[call-arg] + GraphEdgeDef(from_agent="b", to_agent="c"), # type: ignore[call-arg] + ], + ) + assert collect_node_refs(config) == {"a", "b", "c"} + + def test_delegate_single_connection(self) -> None: + """Single-connection delegate still returns entry + child.""" + config = DelegateOrchestrationDef( + entry_name="root", + connections=[DelegateConnectionDef(agent="leaf", description="leaf")], + ) + assert collect_node_refs(config) == {"root", "leaf"} + + +class TestTopologicalSort: + """topological_sort orders orchestrations so dependencies come first.""" + + def test_independent_orchestrations_both_present(self) -> None: + """Two independent swarms can appear in any order but both are returned.""" + configs = { + "orch_a": SwarmOrchestrationDef(entry_name="a1", agents=["a1", "a2"]), + "orch_b": SwarmOrchestrationDef(entry_name="b1", agents=["b1", "b2"]), + } + order = topological_sort(configs) # type: ignore[arg-type] + assert set(order) == {"orch_a", "orch_b"} + + def test_dependency_appears_before_dependent(self) -> None: + """orch_b references orch_a as a node -> orch_a must come before orch_b.""" + configs = { + "orch_a": SwarmOrchestrationDef(entry_name="a1", agents=["a1", "a2"]), + "orch_b": GraphOrchestrationDef( + entry_name="orch_a", + edges=[ + GraphEdgeDef(from_agent="orch_a", to_agent="reviewer"), # type: ignore[call-arg] + ], + ), + } + order = topological_sort(configs) # type: ignore[arg-type] + assert order.index("orch_a") < order.index("orch_b") + + def test_circular_dependency_raises_configuration_error(self) -> None: + """Mutual references between orchestrations raise ConfigurationError.""" + configs = { + "orch_a": GraphOrchestrationDef( + entry_name="orch_b", + edges=[GraphEdgeDef(from_agent="orch_b", to_agent="x")], # type: ignore[call-arg] + ), + "orch_b": GraphOrchestrationDef( + entry_name="orch_a", + edges=[GraphEdgeDef(from_agent="orch_a", to_agent="y")], # type: ignore[call-arg] + ), + } + with pytest.raises(ConfigurationError, match="Circular dependency"): + topological_sort(configs) # type: ignore[arg-type] + + def test_three_level_chain_correct_order(self) -> None: + """A -> B -> C chain: C built first, then B, then A.""" + configs = { + "A": GraphOrchestrationDef( + entry_name="B", + edges=[GraphEdgeDef(from_agent="B", to_agent="agent1")], # type: ignore[call-arg] + ), + "B": GraphOrchestrationDef( + entry_name="C", + edges=[GraphEdgeDef(from_agent="C", to_agent="agent2")], # type: ignore[call-arg] + ), + "C": SwarmOrchestrationDef(entry_name="agent3", agents=["agent3", "agent4"]), + } + order = topological_sort(configs) # type: ignore[arg-type] + assert order.index("C") < order.index("B") < order.index("A") diff --git a/tests/unit/config/resolvers/orchestrations/test_tools.py b/tests/unit/config/resolvers/orchestrations/test_tools.py new file mode 100644 index 0000000..ccc7798 --- /dev/null +++ b/tests/unit/config/resolvers/orchestrations/test_tools.py @@ -0,0 +1,581 @@ +"""Tests for orchestrations.tools — node_as_tool and node_as_async_tool.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast +from unittest.mock import MagicMock + +import pytest +from strands import Agent as _Agent +from strands.agent.agent_result import AgentResult +from strands.multiagent.base import MultiAgentBase, MultiAgentResult, NodeResult, Status +from strands.multiagent.graph import GraphResult +from strands.multiagent.swarm import SwarmResult +from strands.types.content import Message + +from strands_compose.tools import ( + node_as_async_tool, + node_as_tool, +) +from strands_compose.tools.extractors import ( + extract_last_text_block, + extract_text_from_message, + extract_text_from_node_result, + resolve_last_node_id, +) + +# --------------------------------------------------------------------------- +# Helpers — lightweight stand-ins for SwarmNode / GraphNode dataclasses. +# Real SwarmNode/GraphNode require a full Agent executor; these fakes +# carry only ``node_id`` which is all ``resolve_last_node_id`` reads. +# --------------------------------------------------------------------------- + + +@dataclass +class _FakeSwarmNode: + node_id: str + + +@dataclass +class _FakeGraphNode: + node_id: str + + +def _msg(content: list[dict[str, Any]]) -> Message: + """Build a ``Message`` dict with ``role`` and ``content``.""" + return cast(Message, {"role": "assistant", "content": content}) + + +def _text_block(text: str) -> dict[str, str]: + """Build a ``ContentBlock`` dict containing a text field.""" + return {"text": text} + + +def _tool_use_block() -> dict[str, Any]: + """Build a ``ContentBlock`` dict containing a toolUse field.""" + return {"toolUse": {"toolUseId": "t1", "name": "calc", "input": {}}} + + +def _agent_result_with_text(text: str) -> AgentResult: + """Build a minimal ``AgentResult`` whose message contains a single text block.""" + return AgentResult( + stop_reason="end_turn", + message=_msg([_text_block(text)]), + metrics=MagicMock(), + state={}, + ) + + +def _agent_result_no_text() -> AgentResult: + """Build an ``AgentResult`` with only toolUse blocks (no text).""" + return AgentResult( + stop_reason="end_turn", + message=_msg([_tool_use_block()]), + metrics=MagicMock(), + state={}, + ) + + +def _node_result(agent_result: AgentResult | MultiAgentResult | Exception) -> NodeResult: + """Wrap an inner result in a ``NodeResult``.""" + return NodeResult(result=agent_result, status=Status.COMPLETED) + + +def _fake_swarm_nodes(*names: str) -> list[Any]: + """Build a list of fake SwarmNode-like objects for ``node_history``.""" + return [_FakeSwarmNode(n) for n in names] + + +def _fake_graph_nodes(*names: str) -> list[Any]: + """Build a list of fake GraphNode-like objects for ``execution_order``.""" + return [_FakeGraphNode(n) for n in names] + + +# =========================================================================== +# extract_text_from_message +# =========================================================================== + + +class TestExtractTextFromMessage: + """Unit tests for extract_text_from_message.""" + + def test_returns_last_text_block(self) -> None: + """Multiple text blocks in content returns the last one.""" + msg = _msg([_text_block("first"), _text_block("second")]) + assert extract_text_from_message(msg) == "second" + + def test_returns_none_for_no_text_blocks(self) -> None: + """Content with only toolUse blocks returns None.""" + msg = _msg([_tool_use_block()]) + assert extract_text_from_message(msg) is None + + def test_returns_none_for_empty_content(self) -> None: + """Empty content list returns None.""" + assert extract_text_from_message(_msg([])) is None + + def test_returns_none_for_none_message(self) -> None: + """None message returns None.""" + assert extract_text_from_message(None) is None + + def test_skips_non_dict_blocks(self) -> None: + """Non-dict items in content are safely skipped.""" + msg = cast(Message, {"role": "assistant", "content": ["raw string", _text_block("ok")]}) + assert extract_text_from_message(msg) == "ok" + + +# =========================================================================== +# extract_last_text_block — AgentResult path +# =========================================================================== + + +class TestExtractLastTextBlockAgentResult: + """extract_last_text_block with AgentResult inputs.""" + + def test_single_text_block(self) -> None: + """Single text block is extracted.""" + result = _agent_result_with_text("hello") + assert extract_last_text_block(result) == "hello" + + def test_multiple_text_blocks_returns_last(self) -> None: + """Only the last text block is returned.""" + result = AgentResult( + stop_reason="end_turn", + message=_msg([_text_block("a"), _text_block("b")]), + metrics=MagicMock(), + state={}, + ) + assert extract_last_text_block(result) == "b" + + def test_no_text_blocks_falls_back_to_str(self) -> None: + """When no text blocks exist, falls back to str(AgentResult).""" + result = _agent_result_no_text() + text = extract_last_text_block(result) + # str(AgentResult) produces empty string when only toolUse blocks exist + assert isinstance(text, str) + + def test_interleaved_tool_use_and_text(self) -> None: + """Text block after toolUse is correctly extracted.""" + result = AgentResult( + stop_reason="end_turn", + message=_msg([_tool_use_block(), _text_block("the answer")]), + metrics=MagicMock(), + state={}, + ) + assert extract_last_text_block(result) == "the answer" + + +# =========================================================================== +# extract_last_text_block — MultiAgentResult path (Swarm) +# =========================================================================== + + +class TestExtractLastTextBlockSwarmResult: + """extract_last_text_block with SwarmResult inputs.""" + + def test_extracts_from_last_swarm_node(self) -> None: + """SwarmResult uses node_history to find the last agent's text.""" + reviewer_result = _agent_result_with_text("reviewed article") + swarm_result = SwarmResult( + status=Status.COMPLETED, + results={ + "researcher": _node_result(_agent_result_with_text("raw research")), + "reviewer": _node_result(reviewer_result), + }, + node_history=_fake_swarm_nodes("researcher", "reviewer"), + ) + assert extract_last_text_block(swarm_result) == "reviewed article" + + def test_extracts_from_single_agent_swarm(self) -> None: + """SwarmResult with a single agent returns that agent's text.""" + swarm_result = SwarmResult( + status=Status.COMPLETED, + results={"only_agent": _node_result(_agent_result_with_text("solo answer"))}, + node_history=_fake_swarm_nodes("only_agent"), + ) + assert extract_last_text_block(swarm_result) == "solo answer" + + def test_empty_node_history_falls_back_to_reverse_scan(self) -> None: + """Empty node_history falls back to scanning results in reverse.""" + swarm_result = SwarmResult( + status=Status.COMPLETED, + results={"agent_a": _node_result(_agent_result_with_text("fallback text"))}, + node_history=[], + ) + assert extract_last_text_block(swarm_result) == "fallback text" + + def test_empty_results_returns_descriptive_fallback(self) -> None: + """SwarmResult with no node results returns a descriptive message.""" + swarm_result = SwarmResult(status=Status.COMPLETED, results={}, node_history=[]) + text = extract_last_text_block(swarm_result) + assert "no text output" in text + + def test_last_node_not_in_results_falls_back_to_reverse_scan(self) -> None: + """node_history references a node not in results — falls back gracefully.""" + swarm_result = SwarmResult( + status=Status.COMPLETED, + results={"agent_a": _node_result(_agent_result_with_text("found via fallback"))}, + node_history=_fake_swarm_nodes("missing_agent"), + ) + assert extract_last_text_block(swarm_result) == "found via fallback" + + +# =========================================================================== +# extract_last_text_block — MultiAgentResult path (Graph) +# =========================================================================== + + +class TestExtractLastTextBlockGraphResult: + """extract_last_text_block with GraphResult inputs.""" + + def test_extracts_from_last_graph_node(self) -> None: + """GraphResult uses execution_order to find the last node's text.""" + graph_result = GraphResult( + status=Status.COMPLETED, + results={ + "step_a": _node_result(_agent_result_with_text("intermediate")), + "step_b": _node_result(_agent_result_with_text("final output")), + }, + execution_order=_fake_graph_nodes("step_a", "step_b"), + ) + assert extract_last_text_block(graph_result) == "final output" + + def test_empty_execution_order_falls_back(self) -> None: + """Empty execution_order falls back to reverse scan of results.""" + graph_result = GraphResult( + status=Status.COMPLETED, + results={"node_x": _node_result(_agent_result_with_text("from reverse scan"))}, + execution_order=[], + ) + assert extract_last_text_block(graph_result) == "from reverse scan" + + +# =========================================================================== +# extract_text_from_node_result — edge cases +# =========================================================================== + + +class TestExtractTextFromNodeResult: + """Unit tests for extract_text_from_node_result.""" + + def test_agent_result_with_text(self) -> None: + """NodeResult wrapping an AgentResult extracts text.""" + nr = _node_result(_agent_result_with_text("answer")) + assert extract_text_from_node_result(nr) == "answer" + + def test_nested_multi_agent_result(self) -> None: + """NodeResult wrapping a nested MultiAgentResult recurses into it.""" + inner_multi = MultiAgentResult( + status=Status.COMPLETED, + results={"inner_agent": _node_result(_agent_result_with_text("nested answer"))}, + ) + nr = _node_result(inner_multi) + assert extract_text_from_node_result(nr) == "nested answer" + + def test_exception_result_returns_error_message(self) -> None: + """NodeResult wrapping an Exception returns a descriptive error string.""" + nr = _node_result(RuntimeError("something broke")) + text = extract_text_from_node_result(nr) + assert text is not None + assert "something broke" in text + + def test_agent_result_without_text_falls_back_to_str(self) -> None: + """AgentResult without text blocks falls back to str(AgentResult).""" + ar = _agent_result_no_text() + nr = _node_result(ar) + text = extract_text_from_node_result(nr) + # str(AgentResult) may be empty string for toolUse-only messages + assert isinstance(text, str) or text is None + + +# =========================================================================== +# resolve_last_node_id +# =========================================================================== + + +class TestResolveLastNodeId: + """Unit tests for resolve_last_node_id.""" + + def test_swarm_result_with_history(self) -> None: + """Returns the last node_id from SwarmResult.node_history.""" + result = SwarmResult( + status=Status.COMPLETED, + node_history=_fake_swarm_nodes("a", "b"), + ) + assert resolve_last_node_id(result) == "b" + + def test_graph_result_with_execution_order(self) -> None: + """Returns the last node_id from GraphResult.execution_order.""" + result = GraphResult( + status=Status.COMPLETED, + execution_order=_fake_graph_nodes("x", "y"), + ) + assert resolve_last_node_id(result) == "y" + + def test_base_multi_agent_result_returns_none(self) -> None: + """Base MultiAgentResult has no history — returns None.""" + result = MultiAgentResult(status=Status.COMPLETED) + assert resolve_last_node_id(result) is None + + def test_empty_history_returns_none(self) -> None: + """Empty node_history returns None (falls through to execution_order check).""" + result = SwarmResult(status=Status.COMPLETED, node_history=[]) + assert resolve_last_node_id(result) is None + + +# =========================================================================== +# node_as_tool with Agent — replaces former agent_as_tool +# =========================================================================== + + +class TestNodeAsToolWithAgent: + """node_as_tool wraps an Agent with strict typing.""" + + def _agent(self, result: AgentResult, agent_id: str = "agent1") -> MagicMock: + agent = MagicMock(spec=_Agent) + agent.return_value = result + agent.agent_id = agent_id + return agent + + def test_node_as_tool_wraps_agent(self) -> None: + """node_as_tool wraps an Agent and produces a working tool.""" + result = _agent_result_with_text("hello") + tool = node_as_tool(self._agent(result, "my_agent"), description="Use agent") + + assert tool.tool_name == "my_agent" + assert tool("test") == "hello" + + +# =========================================================================== +# node_as_tool — sync wrappers +# =========================================================================== + + +class TestNodeAsTool: + """node_as_tool wraps Agent and MultiAgentBase nodes as @tool functions.""" + + def _agent(self, result: AgentResult, agent_id: str = "agent1") -> MagicMock: + agent = MagicMock(spec=_Agent) + agent.return_value = result + agent.agent_id = agent_id + return agent + + def test_wraps_agent(self) -> None: + """node_as_tool wraps an Agent; tool_name equals agent_id.""" + result = _agent_result_with_text("agent response") + tool = node_as_tool(self._agent(result, "my_agent"), description="Use agent") + + assert tool.tool_name == "my_agent" + assert tool("test query") == "agent response" + + def test_wraps_swarm_extracts_last_agent_text(self) -> None: + """node_as_tool with a Swarm node extracts text from the last agent.""" + swarm_result = SwarmResult( + status=Status.COMPLETED, + results={ + "researcher": _node_result(_agent_result_with_text("raw data")), + "reviewer": _node_result(_agent_result_with_text("polished article")), + }, + node_history=_fake_swarm_nodes("researcher", "reviewer"), + ) + multi = MagicMock(spec=MultiAgentBase) + multi.return_value = swarm_result + multi.id = "content_team" + + tool = node_as_tool(multi, name="content_team", description="Content production") + + assert tool.tool_name == "content_team" + assert tool("write an article") == "polished article" + + def test_wraps_graph_extracts_last_node_text(self) -> None: + """node_as_tool with a Graph node extracts text from the last executed node.""" + graph_result = GraphResult( + status=Status.COMPLETED, + results={ + "step1": _node_result(_agent_result_with_text("intermediate")), + "step2": _node_result(_agent_result_with_text("final graph output")), + }, + execution_order=_fake_graph_nodes("step1", "step2"), + ) + multi = MagicMock(spec=MultiAgentBase) + multi.return_value = graph_result + multi.id = "my_graph" + + tool = node_as_tool(multi, name="my_graph", description="Pipeline") + + assert tool("run") == "final graph output" + + def test_custom_name_overrides_agent_id(self) -> None: + """Explicit name= overrides the agent's own agent_id.""" + result = _agent_result_with_text("ok") + tool = node_as_tool( + self._agent(result, "original"), name="custom_name", description="Custom" + ) + + assert tool.tool_name == "custom_name" + + def test_multi_block_response_returns_last_text_block(self) -> None: + """toolUse block followed by text block -> returns the text content.""" + result = AgentResult( + stop_reason="end_turn", + message=_msg([_tool_use_block(), _text_block("answer")]), + metrics=MagicMock(), + state={}, + ) + tool = node_as_tool(self._agent(result), description="desc") + + assert tool("q") == "answer" + + def test_multiple_text_blocks_returns_last(self) -> None: + """Multiple text blocks -> only the last one is returned.""" + result = AgentResult( + stop_reason="end_turn", + message=_msg([_text_block("part1"), _text_block("part2")]), + metrics=MagicMock(), + state={}, + ) + tool = node_as_tool(self._agent(result), description="desc") + + assert tool("q") == "part2" + + def test_no_text_blocks_falls_back_to_str_result(self) -> None: + """Only toolUse blocks -> str(result) fallback.""" + result = _agent_result_no_text() + tool = node_as_tool(self._agent(result), description="desc") + + text = tool("q") + assert isinstance(text, str) + + def test_single_text_block_returns_text(self) -> None: + """Single text block -> returns its text directly.""" + result = _agent_result_with_text("only") + tool = node_as_tool(self._agent(result), description="desc") + + assert tool("q") == "only" + + def test_empty_content_falls_back_to_str_result(self) -> None: + """Empty content list -> str(result) fallback.""" + result = AgentResult( + stop_reason="end_turn", + message=_msg([]), + metrics=MagicMock(), + state={}, + ) + tool = node_as_tool(self._agent(result), description="desc") + + text = tool("q") + assert isinstance(text, str) + + +# =========================================================================== +# node_as_async_tool — async wrappers +# =========================================================================== + + +class TestNodeAsAsyncTool: + """node_as_async_tool wraps agents for async delegation.""" + + def _agent_with_async(self, result: AgentResult, agent_id: str = "agent1") -> MagicMock: + agent = MagicMock(spec=_Agent) + + async def fake_invoke_async(query: str) -> AgentResult: + return result + + agent.invoke_async = fake_invoke_async + agent.agent_id = agent_id + return agent + + @pytest.mark.asyncio + async def test_multi_block_response_returns_text_block(self) -> None: + """toolUse block followed by text block -> returns the text content.""" + result = AgentResult( + stop_reason="end_turn", + message=_msg([_tool_use_block(), _text_block("answer")]), + metrics=MagicMock(), + state={}, + ) + tool = node_as_async_tool(self._agent_with_async(result), description="desc") + + assert await tool("q") == "answer" + + @pytest.mark.asyncio + async def test_multiple_text_blocks_returns_last(self) -> None: + """Multiple text blocks -> only the last one is returned.""" + result = AgentResult( + stop_reason="end_turn", + message=_msg([_text_block("part1"), _text_block("part2")]), + metrics=MagicMock(), + state={}, + ) + tool = node_as_async_tool(self._agent_with_async(result), description="desc") + + assert await tool("q") == "part2" + + @pytest.mark.asyncio + async def test_no_text_blocks_falls_back_to_str_result(self) -> None: + """Only toolUse blocks -> str(result) fallback.""" + result = _agent_result_no_text() + tool = node_as_async_tool(self._agent_with_async(result), description="desc") + + text = await tool("q") + assert isinstance(text, str) + + @pytest.mark.asyncio + async def test_empty_content_falls_back_to_str_result(self) -> None: + """Empty content list -> str(result) fallback.""" + result = AgentResult( + stop_reason="end_turn", + message=_msg([]), + metrics=MagicMock(), + state={}, + ) + tool = node_as_async_tool(self._agent_with_async(result), description="desc") + + text = await tool("q") + assert isinstance(text, str) + + @pytest.mark.asyncio + async def test_async_wraps_swarm_extracts_last_agent_text(self) -> None: + """node_as_async_tool with a Swarm node extracts the last agent's text.""" + swarm_result = SwarmResult( + status=Status.COMPLETED, + results={ + "agent_a": _node_result(_agent_result_with_text("draft")), + "agent_b": _node_result(_agent_result_with_text("final async answer")), + }, + node_history=_fake_swarm_nodes("agent_a", "agent_b"), + ) + multi = MagicMock(spec=MultiAgentBase) + + async def fake_invoke_async(query: str) -> SwarmResult: + return swarm_result + + multi.invoke_async = fake_invoke_async + multi.id = "swarm_orch" + + tool = node_as_async_tool(multi, name="swarm_orch", description="Swarm") + + assert await tool("q") == "final async answer" + + @pytest.mark.asyncio + async def test_async_wraps_graph_extracts_last_node_text(self) -> None: + """node_as_async_tool with a Graph node extracts the last node's text.""" + graph_result = GraphResult( + status=Status.COMPLETED, + results={ + "s1": _node_result(_agent_result_with_text("step 1")), + "s2": _node_result(_agent_result_with_text("graph async final")), + }, + execution_order=_fake_graph_nodes("s1", "s2"), + ) + multi = MagicMock(spec=MultiAgentBase) + + async def fake_invoke_async(query: str) -> GraphResult: + return graph_result + + multi.invoke_async = fake_invoke_async + multi.id = "graph_orch" + + tool = node_as_async_tool(multi, name="graph_orch", description="Graph") + + assert await tool("q") == "graph async final" diff --git a/tests/unit/config/resolvers/test_agents.py b/tests/unit/config/resolvers/test_agents.py new file mode 100644 index 0000000..79b0402 --- /dev/null +++ b/tests/unit/config/resolvers/test_agents.py @@ -0,0 +1,270 @@ +"""Tests for core.config.resolvers.agents — resolve_agents, resolve_orchestration.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from strands import Agent as _RealAgent + +from strands_compose.config.resolvers.agents import resolve_agents +from strands_compose.config.resolvers.orchestrations import resolve_orchestrations +from strands_compose.config.schema import ( + AgentDef, + ConversationManagerDef, + HookDef, + ModelDef, + SessionManagerDef, + SwarmOrchestrationDef, +) +from strands_compose.exceptions import ConfigurationError + + +class TestResolveAgents: + def test_simple_agent_with_model_ref(self, patch_agent_init): + agent_def = AgentDef(model="my-model", system_prompt="You are helpful") + models = {"my-model": MagicMock()} + result = resolve_agents( + {"main": agent_def}, + models=models, # type: ignore[arg-type] + mcp_clients={}, + session_manager=None, + ) + assert "main" in result + agent = result["main"] + assert isinstance(agent, _RealAgent) + assert agent._init_kwargs["model"] is models["my-model"] # type: ignore[unresolved-attribute] + assert agent._init_kwargs["system_prompt"] == "You are helpful" # type: ignore[unresolved-attribute] + + @patch("strands_compose.config.resolvers.agents.resolve_model") + def test_agent_with_inline_model(self, mock_resolve_model, patch_agent_init): + inline_model = ModelDef(provider="bedrock", model_id="nova-v1:0") + agent_def = AgentDef(model=inline_model) + result = resolve_agents( + {"main": agent_def}, models={}, mcp_clients={}, session_manager=None + ) + assert "main" in result + mock_resolve_model.assert_called_once_with(inline_model) + + def test_agent_with_no_model(self, patch_agent_init): + agent_def = AgentDef() + result = resolve_agents( + {"main": agent_def}, models={}, mcp_clients={}, session_manager=None + ) + assert "main" in result + assert result["main"]._init_kwargs["model"] is None # type: ignore[unresolved-attribute] + + @patch("strands_compose.config.resolvers.agents.resolve_tools") + def test_agent_with_tools(self, mock_resolve_tools, patch_agent_init): + tool_obj = MagicMock() + mock_resolve_tools.return_value = [tool_obj] + agent_def = AgentDef(tools=["my.module:my_tool"]) + result = resolve_agents( + {"main": agent_def}, models={}, mcp_clients={}, session_manager=None + ) + assert "main" in result + mock_resolve_tools.assert_called_once_with(["my.module:my_tool"]) + + @patch("strands_compose.config.resolvers.agents.resolve_hook_entry") + def test_agent_with_hooks(self, mock_resolve_hook, patch_agent_init): + mock_hook = MagicMock() + mock_resolve_hook.return_value = mock_hook + hook_def = HookDef(type="strands_compose.hooks.max_calls_guard:MaxToolCallsGuard") + agent_def = AgentDef(hooks=[hook_def]) + result = resolve_agents( + {"main": agent_def}, models={}, mcp_clients={}, session_manager=None + ) + assert "main" in result + mock_resolve_hook.assert_called_once_with(hook_def) + + def test_agent_with_mcp_clients(self, patch_agent_init): + mock_client = MagicMock() + agent_def = AgentDef(mcp=["pg-client"]) + result = resolve_agents( + {"main": agent_def}, + models={}, + mcp_clients={"pg-client": mock_client}, + session_manager=None, + ) + assert "main" in result + assert mock_client in result["main"]._init_kwargs["tools"] # type: ignore[unresolved-attribute] + + @patch("strands_compose.config.resolvers.agents.load_object") + def test_agent_with_custom_type(self, mock_import): + mock_factory = MagicMock(return_value=MagicMock(spec=_RealAgent)) + mock_import.return_value = mock_factory + agent_def = AgentDef(type="my.module:CustomAgent", agent_kwargs={"extra": "val"}) + result = resolve_agents( + {"main": agent_def}, models={}, mcp_clients={}, session_manager=None + ) + assert "main" in result + mock_factory.assert_called_once() + call_kwargs = mock_factory.call_args[1] + assert call_kwargs["extra"] == "val" + + @patch("strands_compose.config.resolvers.agents.load_object") + def test_custom_type_non_agent_raises(self, mock_import): + mock_import.return_value = MagicMock(return_value="not_an_agent") + agent_def = AgentDef(type="my.module:BadFactory") + with pytest.raises(TypeError, match="expected strands.Agent"): + resolve_agents({"main": agent_def}, models={}, mcp_clients={}, session_manager=None) + + def test_agent_without_conversation_manager_passes_none(self, patch_agent_init): + """Agent without conversation_manager config passes None to Agent constructor.""" + agent_def = AgentDef() + result = resolve_agents( + {"main": agent_def}, models={}, mcp_clients={}, session_manager=None + ) + assert result["main"]._init_kwargs["conversation_manager"] is None # type: ignore[unresolved-attribute] + + @patch("strands_compose.config.resolvers.agents.resolve_conversation_manager") + def test_agent_with_conversation_manager_resolves_and_passes( + self, mock_resolve_cm, patch_agent_init + ): + """Agent with conversation_manager config resolves and passes to constructor.""" + mock_cm = MagicMock() + mock_resolve_cm.return_value = mock_cm + cm_def = ConversationManagerDef( + type="strands.agent:SlidingWindowConversationManager", + params={"should_truncate_results": False}, + ) + agent_def = AgentDef(conversation_manager=cm_def) + result = resolve_agents( + {"main": agent_def}, models={}, mcp_clients={}, session_manager=None + ) + mock_resolve_cm.assert_called_once_with(cm_def) + assert result["main"]._init_kwargs["conversation_manager"] is mock_cm # type: ignore[unresolved-attribute] + + @patch("strands_compose.config.resolvers.agents.resolve_conversation_manager") + @patch("strands_compose.config.resolvers.agents.load_object") + def test_custom_factory_receives_resolved_conversation_manager( + self, mock_import, mock_resolve_cm + ): + """Custom agent factory also receives the resolved conversation_manager.""" + mock_cm = MagicMock() + mock_resolve_cm.return_value = mock_cm + mock_factory = MagicMock(return_value=MagicMock(spec=_RealAgent)) + mock_import.return_value = mock_factory + cm_def = ConversationManagerDef( + type="strands.agent:NullConversationManager", + ) + agent_def = AgentDef(type="my.module:CustomAgent", conversation_manager=cm_def) + resolve_agents({"main": agent_def}, models={}, mcp_clients={}, session_manager=None) + call_kwargs = mock_factory.call_args[1] + assert call_kwargs["conversation_manager"] is mock_cm + + +class TestSwarmSessionGuard: + """Tests for the fail-fast swarm + session manager conflict guard.""" + + def test_swarm_agent_inherits_global_sm_raises_configuration_error(self, patch_agent_init): + """Swarm agent that would inherit the global SM raises ConfigurationError.""" + global_sm = MagicMock() + agent_def = AgentDef() # session_manager NOT in model_fields_set + with pytest.raises(ConfigurationError, match="global 'session_manager:'"): + resolve_agents( + {"node": agent_def}, + models={}, + mcp_clients={}, + session_manager=global_sm, + swarm_agent_names={"node"}, + ) + + @patch("strands_compose.config.resolvers.agents.resolve_session_manager") + def test_swarm_agent_with_explicit_per_agent_sm_raises_configuration_error( + self, mock_resolve_sm, patch_agent_init + ): + """Swarm agent with an explicit per-agent session_manager raises ConfigurationError.""" + mock_resolve_sm.return_value = MagicMock() + sm_def = SessionManagerDef(provider="file") + agent_def = AgentDef(session_manager=sm_def) + with pytest.raises(ConfigurationError, match="per-agent 'session_manager:'"): + resolve_agents( + {"node": agent_def}, + models={}, + mcp_clients={}, + session_manager=None, + swarm_agent_names={"node"}, + ) + + def test_swarm_agent_with_explicit_null_sm_opts_out_and_succeeds(self, patch_agent_init): + """Swarm agent with session_manager: ~ (explicit None) opts out and is built.""" + global_sm = MagicMock() + agent_def = AgentDef(session_manager=None) # explicit None -> opt-out + assert "session_manager" in agent_def.model_fields_set + result = resolve_agents( + {"node": agent_def}, + models={}, + mcp_clients={}, + session_manager=global_sm, + swarm_agent_names={"node"}, + ) + assert "node" in result + assert result["node"]._init_kwargs["session_manager"] is None # type: ignore[unresolved-attribute] + + def test_non_swarm_agent_inherits_global_sm(self, patch_agent_init): + """Non-swarm agent with no per-agent SM inherits the global session manager.""" + global_sm = MagicMock() + agent_def = AgentDef() + result = resolve_agents( + {"main": agent_def}, + models={}, + mcp_clients={}, + session_manager=global_sm, + ) + assert result["main"]._init_kwargs["session_manager"] is global_sm # type: ignore[unresolved-attribute] + + @patch("strands_compose.config.resolvers.agents.resolve_session_manager") + def test_non_swarm_agent_with_explicit_sm_uses_per_agent_sm( + self, mock_resolve_sm, patch_agent_init + ): + """Non-swarm agent with an explicit per-agent SM uses that SM, not the global one.""" + per_agent_sm = MagicMock() + mock_resolve_sm.return_value = per_agent_sm + sm_def = SessionManagerDef(provider="file") + agent_def = AgentDef(session_manager=sm_def) + global_sm = MagicMock() + result = resolve_agents( + {"main": agent_def}, + models={}, + mcp_clients={}, + session_manager=global_sm, + ) + assert result["main"]._init_kwargs["session_manager"] is per_agent_sm # type: ignore[unresolved-attribute] + + def test_non_swarm_agent_with_explicit_null_sm_opts_out(self, patch_agent_init): + """Non-swarm agent with session_manager: ~ opts out of the global SM.""" + global_sm = MagicMock() + agent_def = AgentDef(session_manager=None) # explicit None -> opt-out + result = resolve_agents( + {"main": agent_def}, + models={}, + mcp_clients={}, + session_manager=global_sm, + ) + assert result["main"]._init_kwargs["session_manager"] is None # type: ignore[unresolved-attribute] + + +class TestResolveOrchestration: + def test_single_agent_mode_returns_empty_dict(self): + config = MagicMock() + config.orchestrations = {} + agent = MagicMock(spec=_RealAgent) + orchestrators = resolve_orchestrations(config, {"main": agent}, {}, {}, {}) + assert orchestrators == {} + + @patch("strands_compose.config.resolvers.orchestrations.OrchestrationBuilder") + def test_with_named_orchestrations(self, mock_builder_cls): + config = MagicMock() + config.orchestrations = { + "my_swarm": SwarmOrchestrationDef(entry_name="a", agents=["a", "b"]), + } + agents = {"a": MagicMock(spec=_RealAgent), "b": MagicMock(spec=_RealAgent)} + mock_swarm = MagicMock() + mock_builder = MagicMock() + mock_builder.build_all.return_value = {"my_swarm": mock_swarm} + mock_builder_cls.return_value = mock_builder + orchestrators = resolve_orchestrations(config, agents, {}, {}, {}) # type: ignore[arg-type] + assert orchestrators == {"my_swarm": mock_swarm} + mock_builder_cls.assert_called_once() + mock_builder.build_all.assert_called_once() diff --git a/tests/unit/config/resolvers/test_config.py b/tests/unit/config/resolvers/test_config.py new file mode 100644 index 0000000..8121b6a --- /dev/null +++ b/tests/unit/config/resolvers/test_config.py @@ -0,0 +1,145 @@ +"""Tests for core.config.resolvers.config — resolve_infra, ResolvedConfig, and ResolvedInfra.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from strands_compose.config.resolvers.config import ( + ResolvedConfig, + ResolvedInfra, + resolve_infra, +) +from strands_compose.config.schema import ( + AgentDef, + AppConfig, + MCPClientDef, + MCPServerDef, + ModelDef, + SessionManagerDef, +) + + +class TestResolvedConfig: + def test_defaults(self): + mock_entry = MagicMock() + rc = ResolvedConfig(entry=mock_entry) + assert rc.agents == {} + assert rc.entry is mock_entry + assert rc.mcp_lifecycle is not None + + +class TestResolvedInfra: + def test_defaults(self): + infra = ResolvedInfra() + assert infra.models == {} + assert infra.clients == {} + assert infra.session_manager is None + assert infra.mcp_lifecycle is not None + + +class TestResolveAll: + """resolve_infra() is pure — creates objects without starting anything.""" + + def test_minimal_config(self): + config = AppConfig(agents={"main": AgentDef()}, entry="main") + result = resolve_infra(config) + assert isinstance(result, ResolvedInfra) + assert result.models == {} + assert result.clients == {} + assert result.session_manager is None + assert result.mcp_lifecycle._started is False + + @patch("strands_compose.config.resolvers.config.resolve_model") + def test_models_resolved(self, mock_resolve_model): + mock_model = MagicMock() + mock_resolve_model.return_value = mock_model + config = AppConfig( + models={"gpt": ModelDef(provider="bedrock", model_id="nova")}, + agents={"main": AgentDef()}, + entry="main", + ) + result = resolve_infra(config) + mock_resolve_model.assert_called_once() + assert result.models == {"gpt": mock_model} + + @patch("strands_compose.config.resolvers.config.resolve_mcp_server") + def test_mcp_servers_registered_not_started(self, mock_resolve_server): + mock_server = MagicMock() + mock_resolve_server.return_value = mock_server + config = AppConfig( + mcp_servers={"pg": MCPServerDef(type="my.module:PgServer")}, + agents={"main": AgentDef()}, + entry="main", + ) + result = resolve_infra(config) + mock_resolve_server.assert_called_once() + # Server registered in lifecycle but NOT started + assert "pg" in result.mcp_lifecycle.servers + mock_server.start.assert_not_called() + assert result.mcp_lifecycle._started is False + + @patch("strands_compose.config.resolvers.config.resolve_mcp_client") + @patch("strands_compose.config.resolvers.config.resolve_mcp_server") + def test_mcp_clients_resolved(self, mock_resolve_server, mock_resolve_client): + mock_server = MagicMock() + mock_resolve_server.return_value = mock_server + mock_client = MagicMock() + mock_resolve_client.return_value = mock_client + config = AppConfig( + mcp_servers={"pg": MCPServerDef(type="my.module:PgServer")}, + mcp_clients={"pg-client": MCPClientDef(server="pg")}, + agents={"main": AgentDef()}, + entry="main", + ) + result = resolve_infra(config) + mock_resolve_client.assert_called_once() + assert result.clients == {"pg-client": mock_client} + # Both registered in lifecycle + assert "pg" in result.mcp_lifecycle.servers + assert "pg-client" in result.mcp_lifecycle.clients + + @patch("strands_compose.config.resolvers.config.resolve_session_manager") + def test_session_manager_resolved(self, mock_resolve_sm): + mock_sm = MagicMock() + mock_resolve_sm.return_value = mock_sm + config = AppConfig( + session_manager=SessionManagerDef(provider="file"), + agents={"main": AgentDef()}, + entry="main", + ) + result = resolve_infra(config) + mock_resolve_sm.assert_called_once() + assert result.session_manager is mock_sm + + def test_no_session_manager(self): + config = AppConfig(agents={"main": AgentDef()}, entry="main") + result = resolve_infra(config) + assert result.session_manager is None + + @patch("strands_compose.config.resolvers.config.resolve_model") + @patch("strands_compose.config.resolvers.config.resolve_mcp_client") + @patch("strands_compose.config.resolvers.config.resolve_mcp_server") + @patch("strands_compose.config.resolvers.config.resolve_session_manager") + def test_full_infra_pipeline(self, mock_sm, mock_server, mock_client, mock_model): + mock_model.return_value = MagicMock() + mock_server.return_value = MagicMock() + mock_client.return_value = MagicMock() + mock_sm.return_value = MagicMock() + + config = AppConfig( + models={"gpt": ModelDef(provider="bedrock", model_id="nova")}, + mcp_servers={"pg": MCPServerDef(type="my.module:PgServer")}, + mcp_clients={"pg-client": MCPClientDef(server="pg")}, + session_manager=SessionManagerDef(provider="file"), + agents={"main": AgentDef(), "helper": AgentDef()}, + entry="main", + ) + result = resolve_infra(config) + assert isinstance(result, ResolvedInfra) + assert "gpt" in result.models + assert "pg-client" in result.clients + assert result.session_manager is not None + # Lifecycle assembled but NOT started + assert "pg" in result.mcp_lifecycle.servers + assert "pg-client" in result.mcp_lifecycle.clients + assert result.mcp_lifecycle._started is False diff --git a/tests/unit/config/resolvers/test_conversation_manager.py b/tests/unit/config/resolvers/test_conversation_manager.py new file mode 100644 index 0000000..05852fb --- /dev/null +++ b/tests/unit/config/resolvers/test_conversation_manager.py @@ -0,0 +1,119 @@ +"""Tests for config.resolvers.conversation_manager — resolve_conversation_manager.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from strands.agent import ( + ConversationManager, + NullConversationManager, + SlidingWindowConversationManager, + SummarizingConversationManager, +) + +from strands_compose.config.resolvers.conversation_manager import resolve_conversation_manager +from strands_compose.config.schema import ConversationManagerDef + + +class TestResolveConversationManager: + """Tests for resolve_conversation_manager().""" + + def test_sliding_window_default_params(self) -> None: + """SlidingWindowConversationManager with default params.""" + cm_def = ConversationManagerDef( + type="strands.agent:SlidingWindowConversationManager", + ) + result = resolve_conversation_manager(cm_def) + assert isinstance(result, SlidingWindowConversationManager) + + def test_sliding_window_custom_params(self) -> None: + """SlidingWindowConversationManager with custom params forwarded.""" + cm_def = ConversationManagerDef( + type="strands.agent:SlidingWindowConversationManager", + params={"window_size": 20, "should_truncate_results": False}, + ) + result = resolve_conversation_manager(cm_def) + assert isinstance(result, SlidingWindowConversationManager) + assert result.window_size == 20 + assert result.should_truncate_results is False + + def test_null_conversation_manager(self) -> None: + """NullConversationManager with no params.""" + cm_def = ConversationManagerDef( + type="strands.agent:NullConversationManager", + ) + result = resolve_conversation_manager(cm_def) + assert isinstance(result, NullConversationManager) + + def test_summarizing_conversation_manager(self) -> None: + """SummarizingConversationManager with default params.""" + cm_def = ConversationManagerDef( + type="strands.agent:SummarizingConversationManager", + ) + result = resolve_conversation_manager(cm_def) + assert isinstance(result, SummarizingConversationManager) + + def test_summarizing_with_custom_params(self) -> None: + """SummarizingConversationManager forwards custom params.""" + cm_def = ConversationManagerDef( + type="strands.agent:SummarizingConversationManager", + params={"summary_ratio": 0.5, "preserve_recent_messages": 5}, + ) + result = resolve_conversation_manager(cm_def) + assert isinstance(result, SummarizingConversationManager) + assert result.summary_ratio == 0.5 + assert result.preserve_recent_messages == 5 + + def test_no_colon_raises_value_error(self) -> None: + """Type string without colon separator raises ValueError.""" + cm_def = ConversationManagerDef(type="not_a_valid_spec") + with pytest.raises(ValueError, match="not a valid import spec"): + resolve_conversation_manager(cm_def) + + @patch("strands_compose.config.resolvers.conversation_manager.load_object") + def test_non_conversation_manager_raises_type_error(self, mock_load: MagicMock) -> None: + """Resolved class that is not a ConversationManager raises TypeError.""" + mock_load.return_value = MagicMock(return_value="not_a_manager") + cm_def = ConversationManagerDef(type="some.module:BadClass") + with pytest.raises(TypeError, match="expected ConversationManager subclass"): + resolve_conversation_manager(cm_def) + + def test_file_based_conversation_manager(self, tmp_path: object) -> None: + """File-based import path resolves a custom ConversationManager.""" + import pathlib + + tmp = pathlib.Path(str(tmp_path)) + cm_file = tmp / "my_cm.py" + cm_file.write_text( + "from strands.agent.conversation_manager import ConversationManager\n" + "from typing import Any\n" + "class MyCM(ConversationManager):\n" + " def __init__(self, x: int = 1) -> None:\n" + " self.x = x\n" + " def apply_management(self, agent: Any, **kwargs: Any) -> None:\n" + " pass\n" + " def reduce_context(self, agent: Any, e: Exception | None = None, **kwargs: Any) -> None:\n" + " pass\n" + ) + cm_def = ConversationManagerDef(type=f"{cm_file}:MyCM", params={"x": 42}) + result = resolve_conversation_manager(cm_def) + assert isinstance(result, ConversationManager) + assert result.x == 42 # type: ignore[unresolved-attribute] + + @patch("strands_compose.config.resolvers.conversation_manager.load_object") + def test_params_forwarded_to_constructor(self, mock_load: MagicMock) -> None: + """Params dict is spread as kwargs to the resolved class.""" + mock_cls = MagicMock() + mock_instance = MagicMock(spec=ConversationManager) + mock_cls.return_value = mock_instance + mock_load.return_value = mock_cls + + cm_def = ConversationManagerDef( + type="some.module:CustomCM", + params={"window_size": 10, "custom_flag": True}, + ) + result = resolve_conversation_manager(cm_def) + + mock_cls.assert_called_once_with(window_size=10, custom_flag=True) + assert result is mock_instance diff --git a/tests/unit/config/resolvers/test_hooks.py b/tests/unit/config/resolvers/test_hooks.py new file mode 100644 index 0000000..87a6618 --- /dev/null +++ b/tests/unit/config/resolvers/test_hooks.py @@ -0,0 +1,82 @@ +"""Tests for core.config.resolvers.hooks — resolve_hook and resolve_hook_entry.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from strands_compose.config.resolvers.hooks import ( + resolve_hook, + resolve_hook_entry, +) +from strands_compose.config.schema import HookDef + + +class TestResolveHook: + def test_valid_import_path(self): + hook_def = HookDef( + type="strands_compose.hooks.max_calls_guard:MaxToolCallsGuard", + params={"max_calls": 10}, + ) + hook = resolve_hook(hook_def) + assert hook.max_calls == 10 # type: ignore[unresolved-attribute] + + def test_no_colon_raises(self): + hook_def = HookDef(type="not_a_valid_spec") + with pytest.raises(ValueError, match="not a valid import spec"): + resolve_hook(hook_def) + + def test_file_based_hook(self, tmp_path): + hook_file = tmp_path / "my_hook.py" + hook_file.write_text( + "from strands.hooks import HookProvider, HookRegistry\n" + "from typing import Any\n" + "class MyHook(HookProvider):\n" + " def __init__(self, x=1):\n" + " self.x = x\n" + " def register_hooks(self, registry: HookRegistry, **kw: Any) -> None:\n" + " pass\n" + ) + hook_def = HookDef(type=f"{hook_file}:MyHook", params={"x": 42}) + hook = resolve_hook(hook_def) + assert hook.x == 42 # type: ignore[unresolved-attribute] + + def test_non_hook_provider_raises(self, tmp_path): + hook_file = tmp_path / "bad_hook.py" + hook_file.write_text("class NotAHook:\n pass\n") + hook_def = HookDef(type=f"{hook_file}:NotAHook") + with pytest.raises(TypeError, match="expected HookProvider subclass"): + resolve_hook(hook_def) + + @patch("strands_compose.config.resolvers.hooks.load_object") + def test_non_hook_provider_module_path_raises(self, mock_import): + mock_import.return_value = MagicMock(return_value="not_a_hook_provider") + hook_def = HookDef(type="some.module:BadHook") + with pytest.raises(TypeError, match="expected HookProvider subclass"): + resolve_hook(hook_def) + + +class TestLoadHookFromFile: + def test_missing_class_raises(self, tmp_path): + hook_file = tmp_path / "empty_hook.py" + hook_file.write_text("# no classes here\n") + hook_def = HookDef(type=f"{hook_file}:MissingClass") + with pytest.raises(ValueError, match="has no attribute 'MissingClass'"): + resolve_hook(hook_def) + + +class TestResolveHookEntry: + def test_string_entry(self): + hook = resolve_hook_entry( + "strands_compose.hooks.max_calls_guard:MaxToolCallsGuard", + ) + assert hook.max_calls == 25 # default # type: ignore[unresolved-attribute] + + def test_hook_def_entry(self): + entry = HookDef( + type="strands_compose.hooks.max_calls_guard:MaxToolCallsGuard", + params={"max_calls": 5}, + ) + hook = resolve_hook_entry(entry) + assert hook.max_calls == 5 # type: ignore[unresolved-attribute] diff --git a/tests/unit/config/resolvers/test_mcp.py b/tests/unit/config/resolvers/test_mcp.py new file mode 100644 index 0000000..94d9310 --- /dev/null +++ b/tests/unit/config/resolvers/test_mcp.py @@ -0,0 +1,118 @@ +"""Tests for core.config.resolvers.mcp — resolve_mcp_client, resolve_mcp_server, resolve_tools.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from strands_compose.config.resolvers.mcp import ( + resolve_mcp_client, + resolve_mcp_server, + resolve_tools, +) +from strands_compose.config.schema import MCPClientDef, MCPServerDef + + +class TestResolveMcpClient: + @patch("strands_compose.config.resolvers.mcp.create_mcp_client") + def test_url_client(self, mock_create): + client_def = MCPClientDef(url="http://localhost:8000") + resolve_mcp_client(client_def, {}, name="test") + mock_create.assert_called_once_with( + server=None, url="http://localhost:8000", command=None, transport_options=None + ) + + def test_missing_server_ref_raises(self): + client_def = MCPClientDef(server="missing") + with pytest.raises(ValueError, match="not defined"): + resolve_mcp_client(client_def, {}, name="test") + + @patch("strands_compose.config.resolvers.mcp.create_mcp_client") + def test_server_ref_resolved(self, mock_create): + server = MagicMock() + client_def = MCPClientDef(server="pg") + resolve_mcp_client(client_def, {"pg": server}, name="test") + mock_create.assert_called_once_with( + server=server, url=None, command=None, transport_options=None + ) + + @patch("strands_compose.config.resolvers.mcp.create_mcp_client") + def test_command_client(self, mock_create): + client_def = MCPClientDef(command=["python", "-m", "my_server"]) + resolve_mcp_client(client_def, {}, name="test") + mock_create.assert_called_once_with( + server=None, url=None, command=["python", "-m", "my_server"], transport_options=None + ) + + @patch("strands_compose.config.resolvers.mcp.create_mcp_client") + def test_transport_passed_when_set(self, mock_create): + client_def = MCPClientDef(url="http://localhost:8000", transport="streamable_http") + resolve_mcp_client(client_def, {}, name="test") + mock_create.assert_called_once_with( + server=None, + url="http://localhost:8000", + command=None, + transport="streamable_http", + transport_options=None, + ) + + @patch("strands_compose.config.resolvers.mcp.create_mcp_client") + def test_extra_params_forwarded(self, mock_create): + client_def = MCPClientDef(url="http://localhost:8000", params={"timeout": 30}) + resolve_mcp_client(client_def, {}, name="test") + mock_create.assert_called_once_with( + server=None, + url="http://localhost:8000", + command=None, + transport_options=None, + timeout=30, + ) + + def test_missing_server_ref_shows_available(self): + servers = {"alpha": MagicMock(), "beta": MagicMock()} + client_def = MCPClientDef(server="missing") + with pytest.raises(ValueError, match="alpha, beta"): + resolve_mcp_client(client_def, servers, name="test") # type: ignore[arg-type] + + @patch("strands_compose.config.resolvers.mcp.create_mcp_client") + def test_tool_filters_passthrough(self, mock_create): + """tool_filters with allowed/rejected keys passes through to MCPClient.""" + filters = {"allowed": ["read_file", "search"], "rejected": ["delete_file"]} + client_def = MCPClientDef(url="http://localhost:8000", params={"tool_filters": filters}) + resolve_mcp_client(client_def, {}, name="test") + mock_create.assert_called_once_with( + server=None, + url="http://localhost:8000", + command=None, + transport_options=None, + tool_filters=filters, + ) + + +class TestResolveMcpServer: + @patch("strands_compose.config.resolvers.mcp.load_object") + def test_valid_server(self, mock_import): + from strands_compose.mcp.server import MCPServer + + mock_server = MagicMock(spec=MCPServer) + mock_import.return_value = MagicMock(return_value=mock_server) + server_def = MCPServerDef(type="my.module:MyServer", params={"port": 8080}) + result = resolve_mcp_server(server_def, name="pg") + assert result is mock_server + + @patch("strands_compose.config.resolvers.mcp.load_object") + def test_non_mcp_server_raises_type_error(self, mock_import): + mock_import.return_value = MagicMock(return_value="not_a_server") + server_def = MCPServerDef(type="my.module:BadFactory") + with pytest.raises(TypeError, match="expected MCPServer subclass"): + resolve_mcp_server(server_def, name="bad") + + +class TestResolveTools: + @patch("strands_compose.config.resolvers.mcp.resolve_tool_specs") + def test_delegates_to_resolve_tool_specs(self, mock_resolve): + mock_resolve.return_value = ["tool1", "tool2"] + result = resolve_tools(["spec1", "spec2"]) + mock_resolve.assert_called_once_with(["spec1", "spec2"]) + assert result == ["tool1", "tool2"] diff --git a/tests/unit/config/resolvers/test_models.py b/tests/unit/config/resolvers/test_models.py new file mode 100644 index 0000000..ac580cd --- /dev/null +++ b/tests/unit/config/resolvers/test_models.py @@ -0,0 +1,64 @@ +"""Tests for core.config.resolvers.models — resolve_model.""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest +from strands.models import Model + +from strands_compose.config.resolvers.models import resolve_model +from strands_compose.config.schema import ModelDef + + +class TestResolveModel: + @patch("strands.models.bedrock.BedrockModel") + def test_bedrock_model(self, mock_bedrock): + model_def = ModelDef(provider="bedrock", model_id="us.amazon.nova-pro-v1:0") + resolve_model(model_def) + mock_bedrock.assert_called_once() + + @patch("strands.models.ollama.OllamaModel") + def test_ollama_model(self, mock_ollama): + model_def = ModelDef(provider="ollama", model_id="llama3") + resolve_model(model_def) + mock_ollama.assert_called_once() + + @patch("strands_compose.config.resolvers.models.load_object") + def test_custom_model_class(self, mock_import): + class CustomModel(Model): + def __init__(self, **kwargs): + pass + + def update_config(self, **kwargs): + pass + + def get_config(self): + return {} + + def stream(self, *args, **kwargs): + yield from () + + def structured_output(self, *args, **kwargs): + return None + + mock_import.return_value = CustomModel + model_def = ModelDef(provider="my.module:CustomModel", model_id="custom-v1") + result = resolve_model(model_def) + assert isinstance(result, Model) + + @patch("strands_compose.config.resolvers.models.load_object") + def test_custom_model_not_subclass_raises(self, mock_import): + mock_import.return_value = str # str is not a Model subclass + model_def = ModelDef(provider="my.module:NotAModel", model_id="bad") + with pytest.raises(ValueError, match="must be a subclass"): + resolve_model(model_def) + + @patch( + "strands_compose.config.resolvers.models.create_model", + side_effect=RuntimeError("connection failed"), + ) + def test_generic_exception_propagates(self, mock_create): + model_def = ModelDef(provider="bedrock", model_id="bad-model") + with pytest.raises(RuntimeError, match="connection failed"): + resolve_model(model_def) diff --git a/tests/unit/config/resolvers/test_session_manager.py b/tests/unit/config/resolvers/test_session_manager.py new file mode 100644 index 0000000..fb1cada --- /dev/null +++ b/tests/unit/config/resolvers/test_session_manager.py @@ -0,0 +1,234 @@ +"""Tests for core.config.resolvers.session_manager — resolve_session_manager.""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from strands_compose.config.resolvers.session_manager import resolve_session_manager +from strands_compose.config.schema import SessionManagerDef + +# Module path for patching module-level imports in session_manager resolver +_SM = "strands_compose.config.resolvers.session_manager" +_ACM_CONFIG = "bedrock_agentcore.memory.integrations.strands.config.AgentCoreMemoryConfig" +_ACM_MANAGER = ( + "bedrock_agentcore.memory.integrations.strands.session_manager.AgentCoreMemorySessionManager" +) + + +class TestResolveSessionManager: + @patch("strands.session.FileSessionManager") + def test_file_provider_with_session_id_in_params(self, mock_fs): + sm_def = SessionManagerDef( + provider="file", params={"session_id": "abc", "storage_dir": "/data"} + ) + resolve_session_manager(sm_def) + mock_fs.assert_called_once() + call_kwargs = mock_fs.call_args.kwargs + assert call_kwargs["session_id"] == "abc" + assert call_kwargs["storage_dir"] == "/data" + + @patch("strands.session.FileSessionManager") + def test_file_provider_random_session_id_by_default(self, mock_fs): + """Without session_id in params, a random UUID is generated.""" + sm_def = SessionManagerDef(provider="file") + resolve_session_manager(sm_def) + mock_fs.assert_called_once() + call_kwargs = mock_fs.call_args.kwargs + # Verify a UUID was generated (36 chars including hyphens) + assert len(call_kwargs["session_id"]) == 36 + assert "-" in call_kwargs["session_id"] + + @patch("strands.session.FileSessionManager") + def test_session_id_override_takes_precedence(self, mock_fs): + """session_id_override parameter wins over params.""" + sm_def = SessionManagerDef(provider="file", params={"session_id": "from-params"}) + resolve_session_manager(sm_def, session_id_override="from-override") + mock_fs.assert_called_once_with(session_id="from-override") + + @patch("strands.session.S3SessionManager") + def test_s3_provider(self, mock_s3): + sm_def = SessionManagerDef( + provider="s3", + params={"session_id": "s3-session", "bucket": "my-bucket"}, + ) + resolve_session_manager(sm_def) + mock_s3.assert_called_once() + call_kwargs = mock_s3.call_args.kwargs + assert call_kwargs["session_id"] == "s3-session" + assert call_kwargs["bucket"] == "my-bucket" + + @patch("strands.session.S3SessionManager") + def test_s3_provider_random_session_id_by_default(self, mock_s3): + """Without session_id in params, a random UUID is generated.""" + sm_def = SessionManagerDef(provider="s3") + resolve_session_manager(sm_def) + mock_s3.assert_called_once() + call_kwargs = mock_s3.call_args.kwargs + # Verify a UUID was generated (36 chars including hyphens) + assert len(call_kwargs["session_id"]) == 36 + assert "-" in call_kwargs["session_id"] + + def test_unknown_provider_raises(self): + sm_def = SessionManagerDef(provider="dynamodb") + with pytest.raises(ValueError, match="Unknown session provider"): + resolve_session_manager(sm_def) + + @patch("strands.session.FileSessionManager") + def test_provider_case_insensitive(self, mock_fs): + sm_def = SessionManagerDef(provider="FILE", params={"session_id": "test"}) + resolve_session_manager(sm_def) + mock_fs.assert_called_once_with(session_id="test") + + +class TestAgentcoreProvider: + @patch(_ACM_MANAGER) + @patch(_ACM_CONFIG) + def test_agentcore_provider(self, mock_config_cls, mock_manager_cls): + sm_def = SessionManagerDef( + provider="agentcore", + params={ + "session_id": "sess-1", + "memory_id": "mem-abc", + "actor_id": "user-1", + }, + ) + resolve_session_manager(sm_def) + mock_config_cls.assert_called_once_with( + session_id="sess-1", + memory_id="mem-abc", + actor_id="user-1", + ) + mock_manager_cls.assert_called_once_with( + mock_config_cls.return_value, + ) + + @patch(_ACM_MANAGER) + @patch(_ACM_CONFIG) + def test_agentcore_region_extracted_from_params(self, mock_config_cls, mock_manager_cls): + """region_name goes to the manager constructor, not AgentCoreMemoryConfig.""" + sm_def = SessionManagerDef( + provider="agentcore", + params={ + "session_id": "sess-2", + "memory_id": "mem-xyz", + "actor_id": "user-2", + "region_name": "eu-central-1", + }, + ) + resolve_session_manager(sm_def) + mock_config_cls.assert_called_once_with( + session_id="sess-2", + memory_id="mem-xyz", + actor_id="user-2", + ) + mock_manager_cls.assert_called_once_with( + mock_config_cls.return_value, + region_name="eu-central-1", + ) + + @patch(_ACM_MANAGER) + @patch(_ACM_CONFIG) + def test_agentcore_session_id_override(self, mock_config_cls, mock_manager_cls): + """HTTP session_id_override wins over params.session_id.""" + sm_def = SessionManagerDef( + provider="agentcore", + params={"session_id": "from-params", "memory_id": "m", "actor_id": "a"}, + ) + resolve_session_manager(sm_def, session_id_override="runtime-session") + config_call = mock_config_cls.call_args.kwargs + assert config_call["session_id"] == "runtime-session" + + @patch(_ACM_MANAGER) + @patch(_ACM_CONFIG) + def test_agentcore_config_fields_separated_from_constructor( + self, mock_config_cls, mock_manager_cls + ): + """AgentCoreMemoryConfig fields and constructor params are split correctly.""" + sm_def = SessionManagerDef( + provider="agentcore", + params={ + "session_id": "sess-3", + "memory_id": "mem-1", + "actor_id": "user-3", + "batch_size": 10, + "context_tag": "ctx", + "region_name": "us-west-2", + }, + ) + resolve_session_manager(sm_def) + # Config fields go to AgentCoreMemoryConfig + config_kwargs = mock_config_cls.call_args.kwargs + assert config_kwargs["memory_id"] == "mem-1" + assert config_kwargs["batch_size"] == 10 + assert config_kwargs["context_tag"] == "ctx" + assert "region_name" not in config_kwargs + # Constructor params go to AgentCoreMemorySessionManager + manager_call_args = mock_manager_cls.call_args + assert manager_call_args.args[0] is mock_config_cls.return_value + manager_kwargs = manager_call_args.kwargs + assert manager_kwargs["region_name"] == "us-west-2" + assert "memory_id" not in manager_kwargs + + def test_agentcore_missing_actor_id_raises(self): + """agentcore provider requires actor_id in params.""" + sm_def = SessionManagerDef( + provider="agentcore", + params={"memory_id": "mem-1"}, + ) + with pytest.raises(ValueError, match="actor_id"): + resolve_session_manager(sm_def) + + def test_agentcore_missing_package_raises_friendly_error( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """ImportError for agentcore includes the correct pip install command.""" + _blocked = [ + "bedrock_agentcore", + "bedrock_agentcore.memory", + "bedrock_agentcore.memory.integrations", + "bedrock_agentcore.memory.integrations.strands", + "bedrock_agentcore.memory.integrations.strands.config", + "bedrock_agentcore.memory.integrations.strands.session_manager", + ] + for mod in _blocked: + monkeypatch.setitem(sys.modules, mod, None) + sm_def = SessionManagerDef( + provider="agentcore", + params={"memory_id": "mem-1", "actor_id": "user-1"}, + ) + with pytest.raises(ImportError, match=r"pip install strands-compose\[agentcore-memory\]"): + resolve_session_manager(sm_def) + + +class TestCustomTypeProvider: + def test_custom_type_instantiated_with_session_id(self): + """type: module:Class bypasses provider and instantiates with session_id.""" + from strands.session.session_manager import SessionManager + + mock_instance = MagicMock(spec=SessionManager) + mock_cls = MagicMock(return_value=mock_instance) + + with patch(f"{_SM}.load_object", return_value=mock_cls): + sm_def = SessionManagerDef( + type="my_mod:MySessionManager", + params={"session_id": "custom-1", "extra_param": "value"}, + ) + result = resolve_session_manager(sm_def) + + mock_cls.assert_called_once_with(session_id="custom-1", extra_param="value") + assert result is mock_instance + + def test_custom_type_rejects_non_session_manager(self): + """type: raises TypeError if the class is not a SessionManager subclass.""" + mock_instance = MagicMock() # not a SessionManager + + with patch( + f"{_SM}.load_object", + return_value=MagicMock(return_value=mock_instance), + ): + sm_def = SessionManagerDef(type="bad:Class") + with pytest.raises(TypeError, match="must be a subclass of"): + resolve_session_manager(sm_def) diff --git a/tests/unit/config/resolvers/test_wire_event_queue.py b/tests/unit/config/resolvers/test_wire_event_queue.py new file mode 100644 index 0000000..f197621 --- /dev/null +++ b/tests/unit/config/resolvers/test_wire_event_queue.py @@ -0,0 +1,78 @@ +"""Tests for ResolvedConfig.wire_event_queue() method.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from strands_compose.config.resolvers.config import ResolvedConfig +from strands_compose.wire import EventQueue + + +class TestWireEventQueue: + """Unit tests for ResolvedConfig.wire_event_queue().""" + + @patch("strands_compose.config.resolvers.config.make_event_queue") + def test_returns_event_queue(self, mock_make_eq): + mock_eq = MagicMock(spec=EventQueue) + mock_make_eq.return_value = mock_eq + + agent = MagicMock() + agent.agent_id = "a" + agent.hooks = MagicMock() + agent.hooks._registered_callbacks = {} + rc = ResolvedConfig(entry=agent, agents={"a": agent}) + + result = rc.wire_event_queue() + + assert result is mock_eq + mock_make_eq.assert_called_once() + + @patch("strands_compose.config.resolvers.config.make_event_queue") + def test_passes_agents_and_orchestrators(self, mock_make_eq): + mock_make_eq.return_value = MagicMock(spec=EventQueue) + + agent = MagicMock() + agent.agent_id = "a" + agent.hooks = MagicMock() + agent.hooks._registered_callbacks = {} + orch = MagicMock() + + rc = ResolvedConfig( + entry=agent, + agents={"a": agent}, + orchestrators={"o": orch}, + ) + rc.wire_event_queue() + + call_args = mock_make_eq.call_args + assert call_args[0][0] == {"a": agent} + assert call_args[1]["orchestrators"] == {"o": orch} + + @patch("strands_compose.config.resolvers.config.make_event_queue") + def test_forwards_tool_labels(self, mock_make_eq): + mock_make_eq.return_value = MagicMock(spec=EventQueue) + + agent = MagicMock() + agent.agent_id = "a" + agent.hooks = MagicMock() + agent.hooks._registered_callbacks = {} + + rc = ResolvedConfig(entry=agent, agents={"a": agent}) + labels = {"a": "Custom Label"} + rc.wire_event_queue(tool_labels=labels) + + assert mock_make_eq.call_args[1]["tool_labels"] == labels + + @patch("strands_compose.config.resolvers.config.make_event_queue") + def test_none_tool_labels_by_default(self, mock_make_eq): + mock_make_eq.return_value = MagicMock(spec=EventQueue) + + agent = MagicMock() + agent.agent_id = "a" + agent.hooks = MagicMock() + agent.hooks._registered_callbacks = {} + + rc = ResolvedConfig(entry=agent, agents={"a": agent}) + rc.wire_event_queue() + + assert mock_make_eq.call_args[1]["tool_labels"] is None diff --git a/tests/unit/config/test_edge_cases.py b/tests/unit/config/test_edge_cases.py new file mode 100644 index 0000000..8d2fe3d --- /dev/null +++ b/tests/unit/config/test_edge_cases.py @@ -0,0 +1,64 @@ +"""Additional edge-case tests requested by FINAL_REVIEW §7.4.""" + +from __future__ import annotations + +import pytest + +from strands_compose.config.interpolation import interpolate +from strands_compose.config.loaders.helpers import sanitize_name + + +class TestSanitizeNameEdgeCases: + """Extra edge cases for sanitize_name (FINAL_REVIEW §7.4).""" + + def test_unicode_chars_removed(self): + """Unicode characters (not alphanumeric/hyphen) should be replaced.""" + assert sanitize_name("café") == "caf" + + def test_exactly_64_chars_unchanged(self): + name = "a" * 64 + assert sanitize_name(name) == name + + def test_65_chars_truncated(self): + assert len(sanitize_name("a" * 65)) == 64 + + def test_all_spaces(self): + assert sanitize_name(" ") == "" + + def test_mixed_special(self): + """Mixed special characters -> collapsed underscores.""" + assert sanitize_name("a!@#$b") == "a_b" + + def test_numbers_preserved(self): + assert sanitize_name("agent_v2") == "agent_v2" + + +class TestInterpolationEdgeCases: + """Additional edge cases for interpolation (FINAL_REVIEW §7.4).""" + + def test_multiple_placeholders_in_one_string(self): + raw = {"msg": "${A} and ${B}"} + result = interpolate(raw, variables={"A": "x", "B": "y"}, env={}) + assert result["msg"] == "x and y" + + def test_deeply_nested_dict(self): + raw = {"a": {"b": {"c": {"d": "${VAR}"}}}} + result = interpolate(raw, variables={"VAR": "deep"}, env={}) + assert result["a"]["b"]["c"]["d"] == "deep" + + def test_missing_var_in_deeply_nested_raises(self): + raw = {"a": {"b": "${MISSING}"}} + with pytest.raises(ValueError, match="MISSING"): + interpolate(raw, variables={}, env={}) + + def test_empty_dict_no_error(self): + assert interpolate({}, variables={}, env={}) == {} + + def test_empty_list_no_error(self): + raw = {"items": []} + assert interpolate(raw, variables={}, env={}) == {"items": []} + + def test_none_value_preserved(self): + raw = {"key": None} + result = interpolate(raw, variables={}, env={}) + assert result["key"] is None diff --git a/tests/unit/config/test_interpolation.py b/tests/unit/config/test_interpolation.py new file mode 100644 index 0000000..d5a130a --- /dev/null +++ b/tests/unit/config/test_interpolation.py @@ -0,0 +1,92 @@ +"""Tests for core.config.interpolation — variable interpolation.""" + +from __future__ import annotations + +import pytest + +from strands_compose.config.interpolation import interpolate, strip_anchors + + +class TestInterpolate: + def test_simple_var_substitution(self): + raw = {"key": "${MY_VAR}"} + result = interpolate(raw, variables={"MY_VAR": "hello"}, env={}) + assert result["key"] == "hello" + + def test_env_fallback(self): + raw = {"key": "${ENV_VAR}"} + result = interpolate(raw, variables={}, env={"ENV_VAR": "from_env"}) + assert result["key"] == "from_env" + + def test_default_value(self): + raw = {"key": "${MISSING:-default_val}"} + result = interpolate(raw, variables={}, env={}) + assert result["key"] == "default_val" + + def test_missing_var_without_default_raises(self): + raw = {"key": "${MISSING}"} + with pytest.raises(ValueError, match=r"Variable.*MISSING.*is not set"): + interpolate(raw, variables={}, env={}) + + def test_preserves_non_string_values(self): + raw = {"count": 42, "active": True} + result = interpolate(raw, variables={}, env={}) + assert result == {"count": 42, "active": True} + + def test_preserves_type_for_full_var_reference(self): + raw = {"port": "${PORT}"} + result = interpolate(raw, variables={"PORT": 8080}, env={}) + assert result["port"] == 8080 + + def test_nested_dict_interpolation(self): + raw = {"outer": {"inner": "${VAR}"}} + result = interpolate(raw, variables={"VAR": "nested"}, env={}) + assert result["outer"]["inner"] == "nested" + + def test_list_interpolation(self): + raw = {"items": ["${A}", "${B}"]} + result = interpolate(raw, variables={"A": "x", "B": "y"}, env={}) + assert result["items"] == ["x", "y"] + + def test_partial_interpolation_casts_to_str(self): + raw = {"msg": "port=${PORT}"} + result = interpolate(raw, variables={"PORT": 8080}, env={}) + assert result["msg"] == "port=8080" + + def test_vars_lookup_before_env(self): + raw = {"key": "${X}"} + result = interpolate(raw, variables={"X": "from_vars"}, env={"X": "from_env"}) + assert result["key"] == "from_vars" + + def test_cross_variable_resolution(self): + raw = {"b": "${B}"} + result = interpolate(raw, variables={"A": "hello", "B": "${A} world"}, env={}) + assert result["b"] == "hello world" + + def test_chain_variable_resolution(self): + raw = {"c": "${C}"} + result = interpolate(raw, variables={"A": "x", "B": "${A}y", "C": "${B}z"}, env={}) + assert result["c"] == "xyz" + + def test_circular_reference_raises_value_error(self): + with pytest.raises(ValueError, match=r"Unresolved variable reference"): + interpolate({}, variables={"A": "${B}", "B": "${A}"}, env={}) + + def test_self_reference_raises_value_error(self): + with pytest.raises(ValueError, match=r"Unresolved variable reference"): + interpolate({}, variables={"A": "${A}"}, env={}) + + def test_mixed_env_and_var_cross_resolution(self): + raw = {"b": "${B}"} + result = interpolate(raw, variables={"A": "${FOO}", "B": "${A}_suffix"}, env={"FOO": "bar"}) + assert result["b"] == "bar_suffix" + + +class TestStripAnchors: + def test_removes_x_prefixed_keys(self): + raw = {"x-base": {"a": 1}, "agents": {"b": 2}} + assert strip_anchors(raw) == {"agents": {"b": 2}} + + def test_keeps_non_x_keys(self): + raw = {"models": {}, "agents": {}} + assert strip_anchors(raw) == raw diff --git a/tests/unit/config/test_schema.py b/tests/unit/config/test_schema.py new file mode 100644 index 0000000..cde35e3 --- /dev/null +++ b/tests/unit/config/test_schema.py @@ -0,0 +1,191 @@ +"""Tests for core.config.schema — Pydantic model validation.""" + +from __future__ import annotations + +import pytest +from pydantic import ValidationError + +from strands_compose.config.schema import ( + AgentDef, + AppConfig, + DelegateConnectionDef, + DelegateOrchestrationDef, + MCPClientDef, + MCPServerDef, + SwarmOrchestrationDef, +) + + +class TestMCPClientDef: + def test_exactly_one_connection_mode_required(self): + with pytest.raises(ValidationError, match="exactly one"): + MCPClientDef() + + def test_multiple_modes_rejected(self): + with pytest.raises(ValidationError, match="exactly one"): + MCPClientDef(server="s", url="http://x") + + def test_valid_server_mode(self): + c = MCPClientDef(server="my-server") + assert c.server == "my-server" + + def test_valid_url_mode(self): + c = MCPClientDef(url="http://localhost:8000") + assert c.url == "http://localhost:8000" + + def test_valid_command_mode(self): + c = MCPClientDef(command=["python", "-m", "server"]) + assert c.command == ["python", "-m", "server"] + + +class TestAgentDef: + def test_defaults(self): + a = AgentDef() + assert a.tools == [] + assert a.hooks == [] + assert a.mcp == [] + assert a.model is None + + def test_agent_kwargs_accepted(self): + a = AgentDef(agent_kwargs={"retry": True}) + assert a.agent_kwargs == {"retry": True} + + def test_agent_kwargs_defaults_to_empty_dict(self): + a = AgentDef() + assert a.agent_kwargs == {} + + +class TestDelegateOrchestrationDef: + def test_basic_construction(self): + orch = DelegateOrchestrationDef( + entry_name="parent", + connections=[DelegateConnectionDef(agent="child", description="sub")], + ) + assert orch.entry_name == "parent" + assert len(orch.connections) == 1 + assert orch.session_manager is None + assert orch.agent_kwargs == {} + + def test_session_manager_allowed(self): + from strands_compose.config.schema import SessionManagerDef + + orch = DelegateOrchestrationDef( + entry_name="parent", + connections=[DelegateConnectionDef(agent="child", description="sub")], + session_manager=SessionManagerDef(), + ) + assert orch.session_manager is not None + + def test_agent_kwargs_override(self): + orch = DelegateOrchestrationDef( + entry_name="parent", + connections=[DelegateConnectionDef(agent="child", description="sub")], + agent_kwargs={"max_tool_calls": 50}, + ) + assert orch.agent_kwargs == {"max_tool_calls": 50} + + +class TestAppConfig: + def test_entry_ref_validation(self): + with pytest.raises(ValidationError, match=r"entry.*not defined"): + AppConfig( + agents={"a": AgentDef()}, + entry="nonexistent", + ) + + def test_valid_config(self): + cfg = AppConfig( + agents={"assistant": AgentDef(system_prompt="Hi")}, + entry="assistant", + ) + assert cfg.entry == "assistant" + + def test_empty_config_missing_entry_raises(self): + with pytest.raises(ValidationError, match="entry"): + AppConfig(entry="nonexistent") + + def test_version_defaults_to_one(self): + cfg = AppConfig(agents={"a": AgentDef()}, entry="a") + assert cfg.version == "1" + + def test_version_can_be_set(self): + cfg = AppConfig(agents={"a": AgentDef()}, entry="a", version="1") + assert cfg.version == "1" + + +class TestOrchestrations: + def test_orchestrations_ok(self): + cfg = AppConfig( + agents={"a": AgentDef(), "b": AgentDef()}, + orchestrations={ + "my_swarm": SwarmOrchestrationDef(entry_name="a", agents=["a", "b"]), + }, + entry="my_swarm", + ) + assert "my_swarm" in cfg.orchestrations + + +class TestNameCollisionValidation: + def test_agent_orch_name_collision_rejected(self): + with pytest.raises(ValidationError, match="Name collision"): + AppConfig( + agents={"overlap": AgentDef()}, + orchestrations={ + "overlap": SwarmOrchestrationDef(entry_name="overlap", agents=["overlap"]), + }, + entry="overlap", + ) + + def test_no_collision_ok(self): + cfg = AppConfig( + agents={"agent1": AgentDef(), "agent2": AgentDef()}, + orchestrations={ + "my_swarm": SwarmOrchestrationDef(entry_name="agent1", agents=["agent1", "agent2"]), + }, + entry="my_swarm", + ) + assert "agent1" in cfg.agents + assert "my_swarm" in cfg.orchestrations + + def test_agent_mcp_server_same_name_allowed(self): + # mcp_servers are a separate namespace from agents — sharing a name is fine + cfg = AppConfig( + agents={"db": AgentDef()}, + mcp_servers={"db": MCPServerDef(type="my.module:Factory")}, + entry="db", + ) + assert "db" in cfg.agents + assert "db" in cfg.mcp_servers + + def test_mcp_server_mcp_client_same_name_allowed(self): + # mcp_servers and mcp_clients are independent namespaces — sharing a name is fine + cfg = AppConfig( + mcp_servers={"postgres": MCPServerDef(type="my.module:Factory")}, + mcp_clients={"postgres": MCPClientDef(url="http://localhost:8080")}, + agents={"a": AgentDef()}, + entry="a", + ) + assert "postgres" in cfg.mcp_servers + assert "postgres" in cfg.mcp_clients + + +class TestEntryRefValidation: + def test_entry_can_reference_orchestration(self): + cfg = AppConfig( + agents={"a": AgentDef(), "b": AgentDef()}, + orchestrations={ + "my_swarm": SwarmOrchestrationDef(entry_name="a", agents=["a", "b"]), + }, + entry="my_swarm", + ) + assert cfg.entry == "my_swarm" + + def test_entry_invalid_ref_rejected(self): + with pytest.raises(ValidationError, match="not defined"): + AppConfig( + agents={"a": AgentDef()}, + orchestrations={ + "my_swarm": SwarmOrchestrationDef(entry_name="a", agents=["a"]), + }, + entry="nonexistent", + ) diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..de69990 --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,250 @@ +"""Shared test fixtures for strands-compose.""" + +import textwrap +import threading +from unittest.mock import MagicMock, patch + +import pytest +from strands import Agent as _RealAgent + +# -- Mock Agent -------------------------------------------------------- # + + +@pytest.fixture +def mock_agent(): + """Create a mock strands Agent for testing hooks and wrappers. + + The mock has: + - agent_id = "test-agent" + - tool_registry with registry dict + - hook_registry that records add_hook calls + - __call__ returns a mock AgentResult + """ + agent = MagicMock() + agent.agent_id = "test-agent" + agent.tool_registry = MagicMock() + agent.tool_registry.registry = {} + agent.hook_registry = MagicMock() + + # Mock AgentResult + result = MagicMock() + result.message = {"content": [{"text": "Test response"}]} + agent.return_value = result + + return agent + + +# -- Mock Model -------------------------------------------------------- # + + +@pytest.fixture +def mock_model(): + """Create a mock LLM model.""" + model = MagicMock() + model.__class__.__name__ = "MockModel" + return model + + +# -- Temporary Tool Files ---------------------------------------------- # + + +@pytest.fixture +def tools_dir(tmp_path): + """Create a temporary directory with sample @tool files. + + Creates: + - tools_dir/greet.py: @tool function 'greet' + - tools_dir/calc.py: @tool function 'add_numbers' + - tools_dir/_helpers.py: non-tool file (should be ignored) + """ + tools = tmp_path / "tools" + tools.mkdir() + + greet_file = tools / "greet.py" + greet_file.write_text( + textwrap.dedent("""\ + from strands import tool + + @tool + def greet(name: str) -> str: + \"\"\"Greet someone by name.\"\"\" + return f"Hello, {name}!" + """) + ) + + calc_file = tools / "calc.py" + calc_file.write_text( + textwrap.dedent("""\ + from strands import tool + + @tool + def add_numbers(a: int, b: int) -> int: + \"\"\"Add two numbers.\"\"\" + return a + b + """) + ) + + helper_file = tools / "_helpers.py" + helper_file.write_text("# This is a helper, should be ignored\nHELPER_CONST = 42\n") + + return tools + + +@pytest.fixture +def plain_tools_file(tmp_path): + """Create a .py file with plain (undecorated) public functions. + + Used to verify that only ``@tool``-decorated functions are collected; + plain functions without the decorator should be ignored. + """ + f = tmp_path / "plain_tools.py" + f.write_text( + textwrap.dedent("""\ + def count_words(text: str) -> int: + \"\"\"Count the number of words in the given text.\"\"\" + return len(text.split()) + + + def count_characters(text: str) -> int: + \"\"\"Count characters in the given text, excluding spaces.\"\"\" + return len(text.replace(" ", "")) + + + def _private_helper(): + \"\"\"Should NOT be collected.\"\"\" + pass + """) + ) + return f + + +# -- Sample YAML Configs ---------------------------------------------- # + + +@pytest.fixture +def simple_config_yaml(tmp_path): + """Create a simple YAML config file.""" + config = tmp_path / "config.yaml" + config.write_text( + textwrap.dedent("""\ + agents: + assistant: + system_prompt: "You are a helpful assistant." + max_tool_calls: 10 + entry: assistant + """) + ) + return config + + +# -- Patch Agent Init ------------------------------------------------- # + + +def _noop_init(self, **kwargs): + """No-op Agent init that stores kwargs for test assertions.""" + self._init_kwargs = kwargs + + +@pytest.fixture +def patch_agent_init(): + """Patch Agent.__init__ with a no-op that stores kwargs. + + Use in tests that need to verify what kwargs were passed to Agent() + without actually constructing a real Agent (which requires a model provider). + + The patched Agent stores all constructor kwargs in ``agent._init_kwargs``. + """ + with patch.object(_RealAgent, "__init__", _noop_init): + yield + + +@pytest.fixture +def full_config_yaml(tmp_path): + """Create a full YAML config file with models, MCP, agents.""" + config = tmp_path / "config.yaml" + config.write_text( + textwrap.dedent("""\ + vars: + MODEL_ID: "anthropic.claude-3-sonnet" + + models: + default: + provider: bedrock + model_id: "${MODEL_ID}" + + agents: + orchestrator: + model: default + system_prompt: "You are an orchestrator." + max_tool_calls: 50 + + analyzer: + model: default + system_prompt: "You analyze data." + + orchestrations: + main: + mode: delegate + entry_name: orchestrator + connections: + - agent: analyzer + description: "Analyze data" + + entry: main + """) + ) + return config + + +# -- Threading Helpers ------------------------------------------------- # + + +@pytest.fixture +def stop_event(): + """Create a threading.Event for stop signaling tests.""" + return threading.Event() + + +# -- Hook Event Factories --------------------------------------------- # + + +@pytest.fixture +def make_before_tool_event(): + """Factory for creating mock BeforeToolCallEvent objects.""" + + def factory(tool_name="test_tool", tool_input=None, invocation_state=None): + event = MagicMock() + event.tool_use = { + "id": f"tool_{tool_name}_123", + "name": tool_name, + "input": tool_input or {}, + } + event.invocation_state = invocation_state or {} + event.cancel_tool = False + event.agent = MagicMock() + event.agent.tool_registry.registry = { + "test_tool": MagicMock(), + "query_db": MagicMock(), + "list_tables": MagicMock(), + } + return event + + return factory + + +@pytest.fixture +def make_after_tool_event(): + """Factory for creating mock AfterToolCallEvent objects.""" + + def factory(tool_name="test_tool", result=None, exception=None): + event = MagicMock() + event.tool_use = { + "id": f"tool_{tool_name}_123", + "name": tool_name, + "input": {}, + } + event.result = result or {"content": [{"text": "Tool output"}]} + event.exception = exception + return event + + return factory diff --git a/tests/unit/converters/__init__.py b/tests/unit/converters/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/converters/test_base.py b/tests/unit/converters/test_base.py new file mode 100644 index 0000000..9aba1ab --- /dev/null +++ b/tests/unit/converters/test_base.py @@ -0,0 +1,47 @@ +"""Tests for the StreamConverter ABC.""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from strands_compose.converters.base import StreamConverter +from strands_compose.converters.openai import OpenAIStreamConverter +from strands_compose.wire import StreamEvent + + +class _ConcreteConverter(StreamConverter): + """Minimal concrete subclass to allow direct instantiation of the ABC.""" + + def convert(self, event: StreamEvent) -> list[dict[str, Any]]: + """Return empty list.""" + return [] + + def done_marker(self) -> str: + """Return empty string.""" + return "" + + +class TestStreamConverterABC: + """StreamConverter ABC contract tests.""" + + def test_cannot_instantiate_abstract_class_directly(self) -> None: + """StreamConverter cannot be instantiated without implementing abstract methods.""" + with pytest.raises(TypeError): + StreamConverter() + + def test_concrete_subclass_instantiates(self) -> None: + """A fully concrete subclass can be instantiated.""" + conv = _ConcreteConverter() + assert conv is not None + + def test_default_content_type_is_text_event_stream(self) -> None: + """content_type() defaults to 'text/event-stream'.""" + conv = _ConcreteConverter() + assert conv.content_type() == "text/event-stream" + + def test_openai_converter_is_instance_of_stream_converter(self) -> None: + """OpenAIStreamConverter must be a StreamConverter subclass.""" + conv = OpenAIStreamConverter() + assert isinstance(conv, StreamConverter) diff --git a/tests/unit/converters/test_openai.py b/tests/unit/converters/test_openai.py new file mode 100644 index 0000000..1c04caf --- /dev/null +++ b/tests/unit/converters/test_openai.py @@ -0,0 +1,440 @@ +"""Regression tests for OpenAIStreamConverter. + +Specifically guards the fix for B1: finish_reason must always be "stop" on the +COMPLETE event, regardless of whether tool calls occurred during the stream. +""" + +from __future__ import annotations + +import pytest + +from strands_compose.converters.openai import OpenAIStreamConverter +from strands_compose.types import EventType +from strands_compose.wire import StreamEvent + +AGENT = "test-agent" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _event(type_: str, **data: object) -> StreamEvent: + """Build a StreamEvent with the given type and payload.""" + return StreamEvent(type=type_, agent_name=AGENT, data=dict(data)) + + +def _finish_reason(chunks: list[dict]) -> str | None: + """Return the finish_reason from the first choice of the first chunk.""" + return chunks[0]["choices"][0]["finish_reason"] + + +# --------------------------------------------------------------------------- +# finish_reason regression tests (B1) +# --------------------------------------------------------------------------- + + +class TestFinishReasonAlwaysStop: + """finish_reason must be 'stop' on COMPLETE regardless of tool usage.""" + + def test_finish_reason_is_stop_without_tool_calls(self) -> None: + """COMPLETE after a plain token stream emits finish_reason 'stop'.""" + conv = OpenAIStreamConverter() + conv.convert(_event(EventType.TOKEN, text="hello")) + chunks = conv.convert(_event(EventType.COMPLETE, usage={})) + + assert len(chunks) == 1 + assert _finish_reason(chunks) == "stop" + + def test_finish_reason_is_stop_after_tool_calls(self) -> None: + """COMPLETE after TOOL_START/TOOL_END must still emit finish_reason 'stop'. + + Regression: the old code returned 'tool_calls' here, which caused + OpenAI-compatible clients (LibreChat, OpenWebUI, Continue.dev) to + interpret the completion as a pending tool-execution step and loop + forever. + """ + conv = OpenAIStreamConverter() + conv.convert( + _event( + EventType.TOOL_START, + tool_name="search", + tool_use_id="call_abc", + tool_input={"q": "hello"}, + ) + ) + conv.convert(_event(EventType.TOOL_END, tool_use_id="call_abc", result="ok")) + chunks = conv.convert(_event(EventType.COMPLETE, usage={})) + + assert len(chunks) == 1 + assert _finish_reason(chunks) != "tool_calls", ( + "finish_reason must never be 'tool_calls' on COMPLETE — that " + "causes clients to loop expecting pending tool results" + ) + assert _finish_reason(chunks) == "stop" + + def test_finish_reason_is_stop_after_multiple_tool_calls(self) -> None: + """COMPLETE after multiple TOOL_START events still emits 'stop'.""" + conv = OpenAIStreamConverter() + for i in range(3): + conv.convert( + _event( + EventType.TOOL_START, + tool_name=f"tool_{i}", + tool_use_id=f"call_{i}", + tool_input={}, + ) + ) + chunks = conv.convert(_event(EventType.COMPLETE, usage={})) + + assert _finish_reason(chunks) == "stop" + + +# --------------------------------------------------------------------------- +# _has_tool_calls tracking +# --------------------------------------------------------------------------- + + +class TestHasToolCallsTracking: + """_has_tool_calls is still set correctly — it is used for metrics.""" + + def test_has_tool_calls_false_initially(self) -> None: + """_has_tool_calls starts as False on a fresh converter.""" + conv = OpenAIStreamConverter() + assert conv._has_tool_calls is False # noqa: SLF001 + + def test_has_tool_calls_set_true_on_tool_start(self) -> None: + """_has_tool_calls is set to True when a TOOL_START event is processed.""" + conv = OpenAIStreamConverter() + assert conv._has_tool_calls is False # noqa: SLF001 + + conv.convert( + _event( + EventType.TOOL_START, + tool_name="do_thing", + tool_use_id="call_x", + tool_input={}, + ) + ) + + assert conv._has_tool_calls is True # noqa: SLF001 + + def test_has_tool_calls_remains_false_without_tool_start(self) -> None: + """_has_tool_calls stays False when no TOOL_START events are seen.""" + conv = OpenAIStreamConverter() + conv.convert(_event(EventType.TOKEN, text="hi")) + conv.convert(_event(EventType.COMPLETE, usage={})) + + assert conv._has_tool_calls is False # noqa: SLF001 + + def test_has_tool_calls_true_persists_through_complete(self) -> None: + """_has_tool_calls remains True after the COMPLETE event fires.""" + conv = OpenAIStreamConverter() + conv.convert(_event(EventType.TOOL_START, tool_name="t", tool_use_id="c", tool_input={})) + conv.convert(_event(EventType.COMPLETE, usage={})) + + assert conv._has_tool_calls is True # noqa: SLF001 + + +# --------------------------------------------------------------------------- +# COMPLETE chunk shape +# --------------------------------------------------------------------------- + + +class TestCompleteChunkShape: + """Verify the overall shape of the COMPLETE chunk.""" + + def test_complete_chunk_includes_usage(self) -> None: + """COMPLETE chunk maps usage fields from strands to OpenAI names.""" + conv = OpenAIStreamConverter() + chunks = conv.convert( + _event( + EventType.COMPLETE, + usage={"input_tokens": 10, "output_tokens": 20, "total_tokens": 30}, + ) + ) + + assert len(chunks) == 1 + usage = chunks[0]["usage"] + assert usage["prompt_tokens"] == 10 + assert usage["completion_tokens"] == 20 + assert usage["total_tokens"] == 30 + + def test_complete_chunk_usage_defaults_to_zero_when_missing(self) -> None: + """Missing usage fields default to 0, not KeyError.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.COMPLETE, usage={})) + + usage = chunks[0]["usage"] + assert usage["prompt_tokens"] == 0 + assert usage["completion_tokens"] == 0 + assert usage["total_tokens"] == 0 + + def test_complete_chunk_delta_is_empty(self) -> None: + """The delta on the COMPLETE chunk is an empty dict.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.COMPLETE, usage={})) + + assert chunks[0]["choices"][0]["delta"] == {} + + @pytest.mark.parametrize( + ("field",), + [("id",), ("object",), ("created",), ("model",), ("choices",), ("usage",)], + ) + def test_complete_chunk_has_required_openai_fields(self, field: str) -> None: + """Each required OpenAI chunk field is present on the COMPLETE chunk.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.COMPLETE, usage={})) + assert field in chunks[0] + + +# --------------------------------------------------------------------------- +# REASONING event conversion (R1 — coverage gap) +# --------------------------------------------------------------------------- + + +class TestReasoningEvent: + """OpenAIStreamConverter REASONING -> delta.reasoning_content.""" + + def test_reasoning_produces_reasoning_content_delta(self) -> None: + """REASONING event produces a chunk with delta.reasoning_content.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.REASONING, text="thinking about it")) + + assert len(chunks) == 1 + delta = chunks[0]["choices"][0]["delta"] + assert delta["reasoning_content"] == "thinking about it" + assert chunks[0]["choices"][0]["finish_reason"] is None + + def test_reasoning_first_chunk_includes_role(self) -> None: + """First REASONING chunk includes role='assistant'.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.REASONING, text="step 1")) + + delta = chunks[0]["choices"][0]["delta"] + assert delta["role"] == "assistant" + + def test_reasoning_subsequent_chunk_no_role(self) -> None: + """Second REASONING chunk does not include role.""" + conv = OpenAIStreamConverter() + conv.convert(_event(EventType.REASONING, text="step 1")) + chunks = conv.convert(_event(EventType.REASONING, text="step 2")) + + delta = chunks[0]["choices"][0]["delta"] + assert "role" not in delta + + def test_reasoning_then_token_role_sent_once(self) -> None: + """role='assistant' is sent only on the first content chunk (REASONING or TOKEN).""" + conv = OpenAIStreamConverter() + conv.convert(_event(EventType.REASONING, text="hmm")) + chunks = conv.convert(_event(EventType.TOKEN, text="answer")) + + # TOKEN chunk should NOT have role since it was sent with REASONING + delta = chunks[0]["choices"][0]["delta"] + assert "role" not in delta + + def test_reasoning_empty_text_defaults(self) -> None: + """REASONING with missing text defaults to empty string.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.REASONING)) + + assert chunks[0]["choices"][0]["delta"]["reasoning_content"] == "" + + +# --------------------------------------------------------------------------- +# ERROR event conversion (R1 — coverage gap) +# --------------------------------------------------------------------------- + + +class TestErrorEvent: + """OpenAIStreamConverter ERROR -> finish_reason 'error' + error field.""" + + def test_error_produces_error_finish_reason(self) -> None: + """ERROR event produces finish_reason 'error'.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.ERROR, message="Token expired")) + + assert len(chunks) == 1 + assert chunks[0]["choices"][0]["finish_reason"] == "error" + assert chunks[0]["choices"][0]["delta"] == {} + + def test_error_includes_error_field(self) -> None: + """ERROR event includes top-level 'error' with message and type.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.ERROR, message="Model overloaded")) + + error = chunks[0]["error"] + assert error["message"] == "Model overloaded" + assert error["type"] == "agent_error" + + def test_error_default_message(self) -> None: + """ERROR event uses default message when none provided.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.ERROR)) + + assert chunks[0]["error"]["message"] == "An error occurred" + + def test_error_has_standard_chunk_fields(self) -> None: + """ERROR chunk has id, object, created, model like any other chunk.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.ERROR, message="fail")) + + chunk = chunks[0] + assert "id" in chunk + assert chunk["object"] == "chat.completion.chunk" + assert "created" in chunk + assert chunk["model"] == AGENT + + +# --------------------------------------------------------------------------- +# AGENT_START / passthrough event conversion (R1 — coverage gap) +# --------------------------------------------------------------------------- + + +class TestPassthroughEvents: + """Unknown event types (incl. AGENT_START) use _strands_event extension.""" + + def test_agent_start_wrapped_in_strands_event(self) -> None: + """AGENT_START is wrapped in _strands_event extension field.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.AGENT_START)) + + assert len(chunks) == 1 + chunk = chunks[0] + assert "_strands_event" in chunk + assert chunk["_strands_event"]["type"] == EventType.AGENT_START + assert chunk["choices"][0]["finish_reason"] is None + + def test_node_start_wrapped_in_strands_event(self) -> None: + """NODE_START is wrapped in _strands_event extension field.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.NODE_START, node_id="researcher")) + + assert "_strands_event" in chunks[0] + assert chunks[0]["_strands_event"]["data"]["node_id"] == "researcher" + + def test_tool_end_wrapped_in_strands_event(self) -> None: + """TOOL_END is wrapped in _strands_event extension (no OpenAI equivalent).""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.TOOL_END, tool_use_id="call_1", result="ok")) + + assert "_strands_event" in chunks[0] + assert chunks[0]["_strands_event"]["type"] == EventType.TOOL_END + + def test_multiagent_complete_wrapped_in_strands_event(self) -> None: + """MULTIAGENT_COMPLETE is wrapped in _strands_event.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.MULTIAGENT_COMPLETE, multiagent_type="swarm")) + + assert "_strands_event" in chunks[0] + + def test_custom_unknown_event_wrapped_in_strands_event(self) -> None: + """Completely unknown event type is also wrapped in _strands_event.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event("custom_event_type", info="x")) + + assert "_strands_event" in chunks[0] + assert chunks[0]["_strands_event"]["type"] == "custom_event_type" + + +# --------------------------------------------------------------------------- +# OpenAI API schema validation (R7) +# --------------------------------------------------------------------------- + + +class TestOpenAISchemaContract: + """Validate output chunks conform to OpenAI chat.completion.chunk schema.""" + + REQUIRED_CHUNK_FIELDS = {"id", "object", "created", "model", "choices"} + + def _validate_chunk(self, chunk: dict) -> None: + """Assert a chunk has all required OpenAI fields and correct types.""" + for field in self.REQUIRED_CHUNK_FIELDS: + assert field in chunk, f"Missing required field: {field}" + assert chunk["object"] == "chat.completion.chunk" + assert isinstance(chunk["id"], str) + assert chunk["id"].startswith("chatcmpl-") + assert isinstance(chunk["created"], int) + assert isinstance(chunk["model"], str) + assert isinstance(chunk["choices"], list) + assert len(chunk["choices"]) >= 1 + choice = chunk["choices"][0] + assert "index" in choice + assert "delta" in choice + assert "finish_reason" in choice + + def test_token_chunk_schema(self) -> None: + """TOKEN chunk conforms to OpenAI schema.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.TOKEN, text="hello")) + self._validate_chunk(chunks[0]) + assert "content" in chunks[0]["choices"][0]["delta"] + + def test_reasoning_chunk_schema(self) -> None: + """REASONING chunk conforms to OpenAI schema.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.REASONING, text="think")) + self._validate_chunk(chunks[0]) + assert "reasoning_content" in chunks[0]["choices"][0]["delta"] + + def test_tool_start_chunk_schema(self) -> None: + """TOOL_START chunk conforms to OpenAI schema.""" + conv = OpenAIStreamConverter() + chunks = conv.convert( + _event( + EventType.TOOL_START, + tool_name="search", + tool_use_id="call_1", + tool_input={"q": "test"}, + ) + ) + self._validate_chunk(chunks[0]) + delta = chunks[0]["choices"][0]["delta"] + assert "tool_calls" in delta + tc = delta["tool_calls"][0] + assert "id" in tc + assert tc["type"] == "function" + assert "name" in tc["function"] + assert "arguments" in tc["function"] + + def test_complete_chunk_schema(self) -> None: + """COMPLETE chunk conforms to OpenAI schema with usage.""" + conv = OpenAIStreamConverter() + chunks = conv.convert( + _event(EventType.COMPLETE, usage={"input_tokens": 5, "output_tokens": 10}) + ) + self._validate_chunk(chunks[0]) + assert "usage" in chunks[0] + usage = chunks[0]["usage"] + assert "prompt_tokens" in usage + assert "completion_tokens" in usage + assert "total_tokens" in usage + + def test_error_chunk_schema(self) -> None: + """ERROR chunk conforms to OpenAI schema.""" + conv = OpenAIStreamConverter() + chunks = conv.convert(_event(EventType.ERROR, message="fail")) + self._validate_chunk(chunks[0]) + assert "error" in chunks[0] + + def test_completion_id_consistent_across_chunks(self) -> None: + """All chunks in a stream share the same completion id.""" + conv = OpenAIStreamConverter() + c1 = conv.convert(_event(EventType.TOKEN, text="a")) + c2 = conv.convert(_event(EventType.TOKEN, text="b")) + c3 = conv.convert(_event(EventType.COMPLETE, usage={})) + + ids = {c1[0]["id"], c2[0]["id"], c3[0]["id"]} + assert len(ids) == 1, "All chunks must share the same completion id" + + def test_custom_completion_id(self) -> None: + """Custom completion_id is used when provided.""" + conv = OpenAIStreamConverter(completion_id="chatcmpl-custom123") + chunks = conv.convert(_event(EventType.TOKEN, text="test")) + assert chunks[0]["id"] == "chatcmpl-custom123" + + def test_done_marker_format(self) -> None: + """done_marker() returns the standard OpenAI SSE sentinel.""" + conv = OpenAIStreamConverter() + assert conv.done_marker() == "data: [DONE]\n\n" diff --git a/tests/unit/converters/test_raw.py b/tests/unit/converters/test_raw.py new file mode 100644 index 0000000..b79da5b --- /dev/null +++ b/tests/unit/converters/test_raw.py @@ -0,0 +1,129 @@ +"""Tests for RawStreamConverter and StreamEvent.from_dict().""" + +from __future__ import annotations + +import pytest + +from strands_compose.converters.raw import RawStreamConverter +from strands_compose.wire import StreamEvent + +AGENT = "test-agent" + + +def _event(type_: str, **data: object) -> StreamEvent: + """Build a StreamEvent with the given type and payload.""" + return StreamEvent(type=type_, agent_name=AGENT, data=dict(data)) + + +class TestRawStreamConverter: + """RawStreamConverter behaviour tests.""" + + def test_convert_returns_event_asdict(self) -> None: + """convert() returns a single-element list with the event's asdict().""" + conv = RawStreamConverter() + event = _event("token", text="hello") + result = conv.convert(event) + + assert result == [event.asdict()] + + def test_convert_returns_list_with_single_element(self) -> None: + """convert() always returns a list of length 1.""" + conv = RawStreamConverter() + event = _event("complete") + result = conv.convert(event) + + assert isinstance(result, list) + assert len(result) == 1 + + def test_done_marker_returns_empty_string(self) -> None: + """done_marker() returns an empty string.""" + conv = RawStreamConverter() + assert conv.done_marker() == "" + + def test_convert_preserves_payload_fields(self) -> None: + """convert() preserves type, agent_name, and data in the output dict.""" + conv = RawStreamConverter() + event = _event("tool_start", tool_name="calculator", tool_input={"x": 1}) + result = conv.convert(event) + + chunk = result[0] + assert chunk["type"] == "tool_start" + assert chunk["agent_name"] == AGENT + assert chunk["data"]["tool_name"] == "calculator" + + @pytest.mark.parametrize( + "event_type", + ["token", "reasoning", "tool_start", "tool_end", "complete", "error"], + ) + def test_convert_works_for_all_event_types(self, event_type: str) -> None: + """convert() handles any event type without raising.""" + conv = RawStreamConverter() + event = _event(event_type) + result = conv.convert(event) + + assert len(result) == 1 + assert result[0]["type"] == event_type + + +class TestStreamEventFromDict: + """StreamEvent.from_dict() round-trip and partial-dict tests.""" + + def test_round_trip(self) -> None: + """from_dict(event.asdict()) reproduces the original event fields.""" + original = _event("token", text="world") + restored = StreamEvent.from_dict(original.asdict()) + + assert restored.type == original.type + assert restored.agent_name == original.agent_name + assert restored.data == original.data + + def test_round_trip_preserves_timestamp(self) -> None: + """from_dict(event.asdict()) faithfully restores the original timestamp.""" + original = _event("token", text="hello") + restored = StreamEvent.from_dict(original.asdict()) + + assert restored.timestamp == original.timestamp + + def test_from_dict_with_only_type_field(self) -> None: + """from_dict() works when only 'type' is present; optional fields default.""" + event = StreamEvent.from_dict({"type": "token", "data": {"text": "hi"}}) + + assert event.type == "token" + assert event.agent_name == "" + assert event.data == {"text": "hi"} + + def test_from_dict_missing_optional_fields_uses_defaults(self) -> None: + """from_dict() sets agent_name='' and data={} when those keys are absent.""" + event = StreamEvent.from_dict({"type": "complete"}) + + assert event.type == "complete" + assert event.agent_name == "" + assert event.data == {} + + def test_from_dict_defaults_type_when_missing(self) -> None: + """from_dict() defaults type to '' when 'type' is absent.""" + event = StreamEvent.from_dict({"agent_name": "foo"}) + assert event.type == "" + assert event.agent_name == "foo" + + def test_from_dict_accepts_datetime_timestamp(self) -> None: + """from_dict() accepts a datetime object for timestamp (no parse needed).""" + from datetime import datetime, timezone + + ts = datetime(2026, 1, 1, tzinfo=timezone.utc) + event = StreamEvent.from_dict({"type": "token", "timestamp": ts}) + assert event.timestamp == ts + + def test_from_dict_defaults_timestamp_on_non_string(self) -> None: + """from_dict() uses now() when timestamp is a non-string, non-datetime.""" + from datetime import datetime + + event = StreamEvent.from_dict({"type": "token", "timestamp": 12345}) + assert isinstance(event.timestamp, datetime) + + def test_from_dict_empty_dict(self) -> None: + """from_dict({}) returns a StreamEvent with all defaults.""" + event = StreamEvent.from_dict({}) + assert event.type == "" + assert event.agent_name == "" + assert event.data == {} diff --git a/tests/unit/hooks/__init__.py b/tests/unit/hooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/hooks/test_edge_cases.py b/tests/unit/hooks/test_edge_cases.py new file mode 100644 index 0000000..2071a43 --- /dev/null +++ b/tests/unit/hooks/test_edge_cases.py @@ -0,0 +1,59 @@ +"""Edge-case tests for modules identified as under-tested in FINAL_REVIEW §7.4.""" + +from __future__ import annotations + +import logging + +from strands_compose.hooks.max_calls_guard import MaxToolCallsGuard + + +class TestMaxToolCallsGuardEdgeCases: + """Edge cases for MaxToolCallsGuard (FINAL_REVIEW §7.4).""" + + def test_max_calls_one_allows_single_call(self, make_before_tool_event): + """max_calls=1 should allow exactly one tool call.""" + guard = MaxToolCallsGuard(max_calls=1) + event = make_before_tool_event() + guard._on_before_tool(event) # call 1 — should be allowed + assert event.cancel_tool is False + + def test_max_calls_one_triggers_on_second(self, make_before_tool_event): + """max_calls=1 should trigger graceful stop on second call.""" + guard = MaxToolCallsGuard(max_calls=1) + event = make_before_tool_event() + guard._on_before_tool(event) # 1 — allowed + guard._on_before_tool(event) # 2 — first violation + assert event.cancel_tool # graceful: cancelled but no hard stop + assert not event.invocation_state.get("request_state", {}).get("stop_event_loop", False) + + def test_max_calls_zero_triggers_on_first(self, make_before_tool_event): + """max_calls=0 should trigger on the very first tool call (degenerate case).""" + guard = MaxToolCallsGuard(max_calls=0) + event = make_before_tool_event() + guard._on_before_tool(event) # 1 — exceeds 0 + assert event.cancel_tool + + def test_default_max_calls_is_25(self): + """Default guard allows 25 calls.""" + guard = MaxToolCallsGuard() + assert guard.max_calls == 25 + + def test_separate_invocation_states_are_independent(self, make_before_tool_event): + """Different invocation_state dicts should track independently.""" + guard = MaxToolCallsGuard(max_calls=1) + event1 = make_before_tool_event(invocation_state={}) + event2 = make_before_tool_event(invocation_state={}) + guard._on_before_tool(event1) # event1: call 1 — OK + guard._on_before_tool(event2) # event2: call 1 — also OK + assert event1.cancel_tool is False + assert event2.cancel_tool is False + + def test_graceful_then_hard_with_max_one(self, make_before_tool_event, caplog): + """With max_calls=1: call 2 = graceful, call 3 = hard stop.""" + guard = MaxToolCallsGuard(max_calls=1) + event = make_before_tool_event() + guard._on_before_tool(event) # 1 — allowed + guard._on_before_tool(event) # 2 — graceful + with caplog.at_level(logging.WARNING): + guard._on_before_tool(event) # 3 — hard stop + assert event.invocation_state.get("request_state", {}).get("stop_event_loop") is True diff --git a/tests/unit/hooks/test_event_publisher.py b/tests/unit/hooks/test_event_publisher.py new file mode 100644 index 0000000..3961456 --- /dev/null +++ b/tests/unit/hooks/test_event_publisher.py @@ -0,0 +1,506 @@ +"""Tests for core.hooks.event_publisher — EventPublisher.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from strands_compose.hooks.event_publisher import ( + EventPublisher, + _extract_result_text, + _resolve_tool_label, +) +from strands_compose.types import EventType + + +class TestExtractResultText: + def test_none_result(self): + assert _extract_result_text(None) is None + + def test_extracts_text(self): + result = {"content": [{"text": "hello"}]} + assert _extract_result_text(result) == "hello" + + +class TestResolveToolLabel: + def test_exact_match(self): + assert _resolve_tool_label("query_db", {"query_db": "Query"}) == "Query" + + def test_prefix_match(self): + assert _resolve_tool_label("query_db_v2", {"query_db": "Query"}) == "Query" + + def test_no_labels(self): + assert _resolve_tool_label("query_db", None) is None + + +class TestEventPublisher: + def test_agent_start_event(self): + events = [] + pub = EventPublisher(callback=events.append, agent_name="test") + agent_start_event = MagicMock() + pub._on_agent_start(agent_start_event) + + assert len(events) == 1 + assert events[0].type == EventType.AGENT_START + assert events[0].agent_name == "test" + assert events[0].data == {"type": "agent"} + + def test_tool_start_and_end_events(self): + events = [] + pub = EventPublisher(callback=events.append, agent_name="test") + registry = MagicMock() + pub.register_hooks(registry) + + # Simulate tool start + tool_start_event = MagicMock() + tool_start_event.tool_use = {"name": "greet", "toolUseId": "t1", "input": {"name": "Bob"}} + pub._on_tool_start(tool_start_event) + + assert len(events) == 1 + assert events[0].type == EventType.TOOL_START + + # Simulate tool end + tool_end_event = MagicMock() + tool_end_event.tool_use = {"name": "greet", "toolUseId": "t1"} + tool_end_event.result = {"content": [{"text": "Hello, Bob!"}]} + tool_end_event.exception = None + pub._on_tool_end(tool_end_event) + + assert events[1].type == EventType.TOOL_END + + def test_callback_handler_emits_tokens(self): + events = [] + pub = EventPublisher(callback=events.append, agent_name="test") + handler = pub.as_callback_handler() + handler(data="Hello") + + assert len(events) == 1 + assert events[0].type == EventType.TOKEN + assert events[0].data["text"] == "Hello" + + def test_complete_emits_with_usage(self): + events = [] + pub = EventPublisher(callback=events.append, agent_name="test") + + # Emit complete + complete_event = MagicMock() + metrics = MagicMock() + metrics.latest_agent_invocation = None + metrics.accumulated_usage = {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15} + complete_event.agent.event_loop_metrics = metrics + pub._on_complete(complete_event) + + assert len(events) == 1 + assert events[0].type == EventType.COMPLETE + assert events[0].data["usage"]["input_tokens"] == 10 + assert events[0].data["usage"]["output_tokens"] == 5 + assert events[0].data["usage"]["total_tokens"] == 15 + + +class TestHandoffEvent: + def test_callback_handler_emits_handoff_on_multiagent_handoff_type(self) -> None: + """as_callback_handler emits HANDOFF when type='multiagent_handoff' is received.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="orch") + handler = pub.as_callback_handler() + handler(type="multiagent_handoff", from_node_ids=["researcher"], to_node_ids=["analyst"]) + + assert len(events) == 1 + assert events[0].type == EventType.HANDOFF + + def test_handoff_event_contains_from_and_to_node_ids(self) -> None: + """HANDOFF event data contains from_node_ids and to_node_ids.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="orch") + handler = pub.as_callback_handler() + handler( + type="multiagent_handoff", + from_node_ids=["researcher"], + to_node_ids=["analyst"], + message="Need calculations", + ) + + evt = events[0] + assert evt.data["from_node_ids"] == ["researcher"] + assert evt.data["to_node_ids"] == ["analyst"] + assert evt.data["message"] == "Need calculations" + + def test_handoff_event_message_none_when_absent(self) -> None: + """HANDOFF event data message is None when not provided.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="orch") + handler = pub.as_callback_handler() + handler(type="multiagent_handoff", from_node_ids=["a"], to_node_ids=["b"]) + + assert events[0].data["message"] is None + + def test_non_handoff_type_does_not_emit_handoff_event(self) -> None: + """as_callback_handler does NOT emit HANDOFF for unrelated type values.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="orch") + handler = pub.as_callback_handler() + handler(type="node_start", node_id="researcher") + + handoff_events = [e for e in events if e.type == EventType.HANDOFF] + assert len(handoff_events) == 0 + + +# --------------------------------------------------------------------------- +# Model error capture (AfterModelCallEvent) +# --------------------------------------------------------------------------- + + +class TestModelErrorCapture: + """EventPublisher emits ERROR events on model failures and suppresses COMPLETE.""" + + def test_model_error_emits_error_event(self) -> None: + """AfterModelCallEvent with exception emits ERROR.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="test") + + model_event = MagicMock() + model_event.exception = RuntimeError("Token has expired") + pub._on_model_error(model_event) + + assert len(events) == 1 + assert events[0].type == EventType.ERROR + assert events[0].agent_name == "test" + assert "Token has expired" in events[0].data["message"] + assert events[0].data["exception_type"] == "RuntimeError" + + def test_model_error_sets_errored_flag(self) -> None: + """_errored flag is set when model error occurs.""" + pub = EventPublisher(callback=lambda _: None, agent_name="test") + assert pub._errored is False + + model_event = MagicMock() + model_event.exception = ValueError("bad request") + pub._on_model_error(model_event) + + assert pub._errored is True + + def test_successful_model_call_does_not_emit_error(self) -> None: + """AfterModelCallEvent without exception emits nothing.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="test") + + model_event = MagicMock() + model_event.exception = None + pub._on_model_error(model_event) + + assert len(events) == 0 + assert pub._errored is False + + def test_complete_suppressed_after_error(self) -> None: + """COMPLETE is not emitted when the invocation errored.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="test") + + # Simulate model error + model_event = MagicMock() + model_event.exception = RuntimeError("auth failed") + pub._on_model_error(model_event) + + # Simulate AfterInvocationEvent (fires in finally block) + complete_event = MagicMock() + metrics = MagicMock() + metrics.latest_agent_invocation = None + metrics.accumulated_usage = {"inputTokens": 0, "outputTokens": 0, "totalTokens": 0} + complete_event.agent.event_loop_metrics = metrics + pub._on_complete(complete_event) + + # Only the ERROR event, no COMPLETE + assert len(events) == 1 + assert events[0].type == EventType.ERROR + + def test_complete_emitted_when_no_error(self) -> None: + """COMPLETE is emitted normally when there was no error.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="test") + + complete_event = MagicMock() + metrics = MagicMock() + metrics.latest_agent_invocation = None + metrics.accumulated_usage = {"inputTokens": 10, "outputTokens": 5, "totalTokens": 15} + complete_event.agent.event_loop_metrics = metrics + pub._on_complete(complete_event) + + assert len(events) == 1 + assert events[0].type == EventType.COMPLETE + + def test_errored_flag_resets_on_next_invocation(self) -> None: + """_errored resets to False when a new invocation starts.""" + pub = EventPublisher(callback=lambda _: None, agent_name="test") + + # First invocation: error + model_event = MagicMock() + model_event.exception = RuntimeError("fail") + pub._on_model_error(model_event) + assert pub._errored is True + + # Second invocation: agent_start resets the flag + agent_start_event = MagicMock() + pub._on_agent_start(agent_start_event) + assert pub._errored is False + + def test_register_hooks_includes_model_error(self) -> None: + """register_hooks registers AfterModelCallEvent callback.""" + from strands.hooks.events import AfterModelCallEvent + + pub = EventPublisher(callback=lambda _: None, agent_name="test") + registry = MagicMock() + pub.register_hooks(registry) + + # Find the AfterModelCallEvent registration + calls = registry.add_callback.call_args_list + registered_events = [call.args[0] for call in calls] + assert AfterModelCallEvent in registered_events + + +# --------------------------------------------------------------------------- +# Multiagent event handlers (R2 — coverage gap) +# --------------------------------------------------------------------------- + + +class TestMultiagentEvents: + """EventPublisher emits multiagent lifecycle events (NODE_START, NODE_STOP, etc.).""" + + def test_node_start_emits_event(self) -> None: + """_on_node_start emits NODE_START with node_id and multiagent_type.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="orchestrator") + + event = MagicMock() + event.node_id = "researcher" + event.source = MagicMock() + event.source.__class__.__name__ = "Swarm" + pub._on_node_start(event) + + assert len(events) == 1 + assert events[0].type == EventType.NODE_START + assert events[0].agent_name == "orchestrator" + assert events[0].data["node_id"] == "researcher" + assert events[0].data["multiagent_type"] == "swarm" + + def test_node_stop_emits_event(self) -> None: + """_on_node_stop emits NODE_STOP with node_id and multiagent_type.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="orchestrator") + + event = MagicMock() + event.node_id = "analyst" + event.source = MagicMock() + event.source.__class__.__name__ = "Graph" + pub._on_node_stop(event) + + assert len(events) == 1 + assert events[0].type == EventType.NODE_STOP + assert events[0].agent_name == "orchestrator" + assert events[0].data["node_id"] == "analyst" + assert events[0].data["multiagent_type"] == "graph" + + def test_node_stop_uses_agent_name_not_event_node_id(self) -> None: + """_on_node_stop uses self._agent_name as agent_name (not event.node_id).""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="my_orchestrator") + + event = MagicMock() + event.node_id = "child_agent" + event.source = MagicMock() + event.source.__class__.__name__ = "Swarm" + pub._on_node_stop(event) + + # Verify agent_name is "my_orchestrator" (not "child_agent") + assert events[0].agent_name == "my_orchestrator" + + def test_multiagent_start_emits_event(self) -> None: + """_on_multiagent_start emits MULTIAGENT_START with type from source class.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="pipeline") + + event = MagicMock() + event.source = MagicMock() + event.source.__class__.__name__ = "Swarm" + pub._on_multiagent_start(event) + + assert len(events) == 1 + assert events[0].type == EventType.MULTIAGENT_START + assert events[0].agent_name == "pipeline" + assert events[0].data["multiagent_type"] == "swarm" + + def test_multiagent_complete_emits_event(self) -> None: + """_on_multiagent_complete emits MULTIAGENT_COMPLETE.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="pipeline") + + event = MagicMock() + event.source = MagicMock() + event.source.__class__.__name__ = "Graph" + pub._on_multiagent_complete(event) + + assert len(events) == 1 + assert events[0].type == EventType.MULTIAGENT_COMPLETE + assert events[0].data["multiagent_type"] == "graph" + + def test_register_hooks_includes_all_multiagent_events(self) -> None: + """register_hooks registers all multiagent event callbacks.""" + from strands.hooks.events import ( + AfterMultiAgentInvocationEvent, + AfterNodeCallEvent, + BeforeMultiAgentInvocationEvent, + BeforeNodeCallEvent, + ) + + pub = EventPublisher(callback=lambda _: None, agent_name="test") + registry = MagicMock() + pub.register_hooks(registry) + + calls = registry.add_callback.call_args_list + registered_events = [call.args[0] for call in calls] + assert BeforeNodeCallEvent in registered_events + assert AfterNodeCallEvent in registered_events + assert BeforeMultiAgentInvocationEvent in registered_events + assert AfterMultiAgentInvocationEvent in registered_events + + def test_full_multiagent_lifecycle_sequence(self) -> None: + """Full lifecycle: multiagent_start -> node_start -> node_stop -> multiagent_complete.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="orch") + + # Simulate full lifecycle + source = MagicMock() + source.__class__.__name__ = "Swarm" + + ma_start = MagicMock() + ma_start.source = source + pub._on_multiagent_start(ma_start) + + node_start = MagicMock() + node_start.node_id = "agent_a" + node_start.source = source + pub._on_node_start(node_start) + + node_stop = MagicMock() + node_stop.node_id = "agent_a" + node_stop.source = source + pub._on_node_stop(node_stop) + + ma_complete = MagicMock() + ma_complete.source = source + pub._on_multiagent_complete(ma_complete) + + assert len(events) == 4 + assert events[0].type == EventType.MULTIAGENT_START + assert events[1].type == EventType.NODE_START + assert events[2].type == EventType.NODE_STOP + assert events[3].type == EventType.MULTIAGENT_COMPLETE + + +# --------------------------------------------------------------------------- +# Callback handler reasoning events +# --------------------------------------------------------------------------- + + +class TestCallbackHandlerReasoningEvents: + """as_callback_handler emits REASONING events for reasoningText kwarg.""" + + def test_reasoning_text_emits_reasoning_event(self) -> None: + """reasoningText kwarg produces a REASONING event.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="test") + handler = pub.as_callback_handler() + handler(reasoningText="Let me think about this...") + + assert len(events) == 1 + assert events[0].type == EventType.REASONING + assert events[0].data["text"] == "Let me think about this..." + + def test_empty_reasoning_text_does_not_emit(self) -> None: + """Empty reasoningText string does not produce an event.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="test") + handler = pub.as_callback_handler() + handler(reasoningText="") + + assert len(events) == 0 + + def test_data_and_reasoning_both_emit(self) -> None: + """Both data and reasoningText in same call emit TOKEN + REASONING.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="test") + handler = pub.as_callback_handler() + handler(data="answer", reasoningText="thought") + + types = [e.type for e in events] + assert EventType.TOKEN in types + assert EventType.REASONING in types + + +# --------------------------------------------------------------------------- +# _safe_callback wrapper +# --------------------------------------------------------------------------- + + +class TestSafeCallbackWrapper: + """_safe_callback wraps callbacks to log exceptions instead of propagating.""" + + def test_runtime_error_is_swallowed(self) -> None: + """RuntimeError in callback is logged, not propagated.""" + + def _failing_cb(event): + raise RuntimeError("connection lost") + + pub = EventPublisher(callback=_failing_cb, agent_name="test") + # Should not raise + pub._callback(MagicMock()) + + def test_os_error_is_swallowed(self) -> None: + """OSError in callback is logged, not propagated.""" + + def _failing_cb(event): + raise OSError("broken pipe") + + pub = EventPublisher(callback=_failing_cb, agent_name="test") + pub._callback(MagicMock()) + + def test_unexpected_exception_is_reraised(self) -> None: + """Non-expected exceptions are logged and re-raised.""" + + def _failing_cb(event): + raise TypeError("unexpected") + + pub = EventPublisher(callback=_failing_cb, agent_name="test") + with pytest.raises(TypeError, match="unexpected"): + pub._callback(MagicMock()) + + +# --------------------------------------------------------------------------- +# _extract_result_text edge cases +# --------------------------------------------------------------------------- + + +class TestExtractResultTextEdgeCases: + """Additional edge cases for _extract_result_text.""" + + def test_json_block_extracted(self) -> None: + """JSON blocks in content are extracted as text.""" + result = {"content": [{"json": {"key": "value"}}]} + text = _extract_result_text(result) + assert text is not None + assert "key" in text + assert "value" in text + + def test_long_text_truncated(self) -> None: + """Text longer than max_len is truncated with '...'.""" + long_text = "x" * 700 + result = {"content": [{"text": long_text}]} + text = _extract_result_text(result, max_len=100) + assert text is not None + assert len(text) == 103 # 100 + "..." + assert text.endswith("...") + + def test_empty_content_returns_none(self) -> None: + """Empty content list returns None.""" + result = {"content": []} + assert _extract_result_text(result) is None diff --git a/tests/unit/hooks/test_max_calls_guard.py b/tests/unit/hooks/test_max_calls_guard.py new file mode 100644 index 0000000..21dcdf3 --- /dev/null +++ b/tests/unit/hooks/test_max_calls_guard.py @@ -0,0 +1,46 @@ +"""Tests for core.hooks.max_calls_guard — MaxToolCallsGuard.""" + +from __future__ import annotations + +import logging + +from strands_compose.hooks.max_calls_guard import MaxToolCallsGuard + + +class TestMaxToolCallsGuard: + def test_allows_calls_under_limit(self, make_before_tool_event): + guard = MaxToolCallsGuard(max_calls=3) + event = make_before_tool_event() + for _ in range(3): + guard._on_before_tool(event) + assert event.cancel_tool is False + + def test_graceful_on_first_over_limit(self, make_before_tool_event, caplog): + """First violation: cancel the tool, warn, but do NOT stop the event loop.""" + guard = MaxToolCallsGuard(max_calls=2) + event = make_before_tool_event() + guard._on_before_tool(event) # 1 + guard._on_before_tool(event) # 2 + with caplog.at_level(logging.WARNING, logger="strands_compose.hooks.max_calls_guard"): + guard._on_before_tool(event) # 3 — first violation + + assert event.cancel_tool # tool was cancelled + assert "Do not call any more tools" in str(event.cancel_tool) + assert event.invocation_state.get("max_tool_calls_guard_limit_hit") is True + # stop_event_loop must NOT be set on the first violation + assert not event.invocation_state.get("request_state", {}).get("stop_event_loop", False) + assert any("tool call limit reached" in m for m in caplog.messages) + + def test_hard_stop_on_second_over_limit(self, make_before_tool_event, caplog): + """Second violation: LLM ignored the warning — hard stop.""" + guard = MaxToolCallsGuard(max_calls=2) + event = make_before_tool_event() + guard._on_before_tool(event) # 1 + guard._on_before_tool(event) # 2 + guard._on_before_tool(event) # 3 — graceful + with caplog.at_level(logging.WARNING, logger="strands_compose.hooks.max_calls_guard"): + guard._on_before_tool(event) # 4 — hard stop + + assert event.cancel_tool + assert event.invocation_state.get("request_state", {}).get("stop_event_loop") is True + assert any("ignored tool call limit" in m for m in caplog.messages) diff --git a/tests/unit/hooks/test_stop_guard.py b/tests/unit/hooks/test_stop_guard.py new file mode 100644 index 0000000..36097a4 --- /dev/null +++ b/tests/unit/hooks/test_stop_guard.py @@ -0,0 +1,119 @@ +"""Tests for core.hooks.stop_guard — StopGuard, MultiAgentStopGuard, stop_guard_from_event.""" + +from __future__ import annotations + +import threading +from unittest.mock import MagicMock + +from strands_compose.hooks.stop_guard import ( + MultiAgentStopGuard, + StopGuard, + stop_guard_from_event, +) + + +class TestStopGuard: + def test_no_cancel_when_check_false(self, make_before_tool_event): + guard = StopGuard(stop_check=lambda: False) + event = make_before_tool_event() + guard._on_before_tool(event) + assert event.cancel_tool is False + + def test_cancels_when_check_true(self, make_before_tool_event): + guard = StopGuard(stop_check=lambda: True) + event = make_before_tool_event() + guard._on_before_tool(event) + assert event.cancel_tool == "Agent stopped by external signal" + + def test_sets_stop_event_loop_on_cancel(self, make_before_tool_event): + """When stop is triggered, request_state.stop_event_loop is set.""" + guard = StopGuard(stop_check=lambda: True) + event = make_before_tool_event() + guard._on_before_tool(event) + assert event.invocation_state["request_state"]["stop_event_loop"] is True + + def test_register_hooks_registers_before_tool(self): + """register_hooks registers BeforeToolCallEvent callback.""" + from strands.hooks.events import BeforeToolCallEvent + + guard = StopGuard(stop_check=lambda: False) + registry = MagicMock() + guard.register_hooks(registry) + + calls = registry.add_callback.call_args_list + registered_events = [call.args[0] for call in calls] + assert BeforeToolCallEvent in registered_events + + +class TestStopGuardFromEvent: + def test_creates_guard_and_event(self): + guard, event = stop_guard_from_event() + assert isinstance(guard, StopGuard) + assert isinstance(event, threading.Event) + + def test_trigger_via_event(self, make_before_tool_event): + guard, stop = stop_guard_from_event() + event = make_before_tool_event() + + guard._on_before_tool(event) + assert event.cancel_tool is False + + stop.set() + event2 = make_before_tool_event() + guard._on_before_tool(event2) + assert event2.cancel_tool == "Agent stopped by external signal" + + def test_accepts_existing_event(self): + """stop_guard_from_event accepts a pre-existing threading.Event.""" + existing = threading.Event() + guard, event = stop_guard_from_event(event=existing) + assert event is existing + + +class TestMultiAgentStopGuard: + """Tests for MultiAgentStopGuard (R3 — coverage gap).""" + + def test_no_cancel_when_check_false(self) -> None: + """Node is not cancelled when stop check returns False.""" + guard = MultiAgentStopGuard(stop_check=lambda: False) + event = MagicMock() + event.cancel_node = False + guard._on_before_node(event) + assert event.cancel_node is False + + def test_cancels_node_when_check_true(self) -> None: + """Node is cancelled when stop check returns True.""" + guard = MultiAgentStopGuard(stop_check=lambda: True) + event = MagicMock() + event.cancel_node = False + guard._on_before_node(event) + assert event.cancel_node == "stop requested" + + def test_register_hooks_registers_before_node(self) -> None: + """register_hooks registers BeforeNodeCallEvent callback.""" + from strands.hooks.events import BeforeNodeCallEvent + + guard = MultiAgentStopGuard(stop_check=lambda: False) + registry = MagicMock() + guard.register_hooks(registry) + + calls = registry.add_callback.call_args_list + registered_events = [call.args[0] for call in calls] + assert BeforeNodeCallEvent in registered_events + + def test_dynamic_stop_check(self) -> None: + """Guard responds to dynamic changes in stop_check return value.""" + flag = threading.Event() + guard = MultiAgentStopGuard(stop_check=flag.is_set) + + event1 = MagicMock() + event1.cancel_node = False + guard._on_before_node(event1) + assert event1.cancel_node is False + + flag.set() + + event2 = MagicMock() + event2.cancel_node = False + guard._on_before_node(event2) + assert event2.cancel_node == "stop requested" diff --git a/tests/unit/hooks/test_tool_name_sanitizer.py b/tests/unit/hooks/test_tool_name_sanitizer.py new file mode 100644 index 0000000..6709a49 --- /dev/null +++ b/tests/unit/hooks/test_tool_name_sanitizer.py @@ -0,0 +1,133 @@ +"""Tests for core.hooks.tool_name_sanitizer — ToolNameSanitizer.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from strands_compose.hooks.tool_name_sanitizer import ( + ToolNameSanitizer, + _sanitize, +) + + +class TestSanitize: + def test_exact_match(self): + assert _sanitize("query", {"query", "list"}) == "query" + + def test_prefix_match_on_garbage(self): + assert _sanitize("query<|extra|>", {"query", "list"}) == "query" + + def test_stripped_match(self): + assert _sanitize("", {"query", "list"}) == "query" + + def test_no_match_returns_none(self): + assert _sanitize("unknown", {"query"}) is None + + def test_no_garbage_no_match_returns_none(self): + assert _sanitize("unknown_tool", {"query"}) is None + + def test_segment_join_with_underscore(self): + """e.g. reporter<|channel|>commentary -> reporter_channel_commentary.""" + assert ( + _sanitize("reporter<|channel|>commentary", {"reporter_channel_commentary"}) + == "reporter_channel_commentary" + ) + + def test_segment_join_with_hyphen(self): + assert _sanitize("foo<|bar|>baz", {"foo-bar-baz"}) == "foo-bar-baz" + + def test_segment_join_concatenated(self): + assert _sanitize("foo<|bar|>baz", {"foobarbaz"}) == "foobarbaz" + + +class TestToolNameSanitizer: + def _make_agent(self, tool_names): + agent = MagicMock() + agent.tool_registry.registry = {n: MagicMock() for n in tool_names} + return agent + + def test_register_hooks(self): + sanitizer = ToolNameSanitizer() + registry = MagicMock() + sanitizer.register_hooks(registry) + assert registry.add_callback.call_count == 2 + + def test_known(self): + agent = self._make_agent(["query", "list"]) + names = ToolNameSanitizer._known(agent) + assert names == {"query", "list"} + + def test_on_after_model_fixes_garbled_name(self): + sanitizer = ToolNameSanitizer() + agent = self._make_agent(["query"]) + event = MagicMock() + event.agent = agent + event.stop_response.message = { + "content": [{"toolUse": {"name": "query<|channel|>", "input": {}}}] + } + sanitizer._on_after_model(event) + assert event.stop_response.message["content"][0]["toolUse"]["name"] == "query" + + def test_on_after_model_skips_valid_name(self): + sanitizer = ToolNameSanitizer() + agent = self._make_agent(["query"]) + event = MagicMock() + event.agent = agent + event.stop_response.message = {"content": [{"toolUse": {"name": "query", "input": {}}}]} + sanitizer._on_after_model(event) + assert event.stop_response.message["content"][0]["toolUse"]["name"] == "query" + + def test_on_after_model_leaves_unresolvable_garbled_name(self): + """Garbled name that can't be mapped is left intact for BeforeToolCall to cancel.""" + sanitizer = ToolNameSanitizer() + agent = self._make_agent(["query"]) + event = MagicMock() + event.agent = agent + event.stop_response.message = {"content": [{"toolUse": {"name": "<|bad|>", "input": {}}}]} + sanitizer._on_after_model(event) + # Name should be unchanged — not stripped to "bad" + assert event.stop_response.message["content"][0]["toolUse"]["name"] == "<|bad|>" + + def test_on_after_model_no_stop_response(self): + sanitizer = ToolNameSanitizer() + event = MagicMock() + event.stop_response = None + sanitizer._on_after_model(event) # should not raise + + def test_on_before_tool_fixes_garbled(self): + sanitizer = ToolNameSanitizer() + agent = self._make_agent(["query"]) + event = MagicMock() + event.agent = agent + event.tool_use = {"name": "query<|extra|>"} + sanitizer._on_before_tool(event) + assert event.tool_use["name"] == "query" + + def test_on_before_tool_cancels_if_no_match(self): + sanitizer = ToolNameSanitizer() + agent = self._make_agent(["query"]) + event = MagicMock() + event.agent = agent + event.tool_use = {"name": "<|totally_unknown|>"} + sanitizer._on_before_tool(event) + assert event.cancel_tool # should be set to error message + + def test_on_before_tool_skips_known_name(self): + sanitizer = ToolNameSanitizer() + agent = self._make_agent(["query"]) + event = MagicMock() + event.agent = agent + event.tool_use = {"name": "query"} + sanitizer._on_before_tool(event) + assert event.tool_use["name"] == "query" + + def test_on_before_tool_skips_clean_unknown_name(self): + """Clean unknown names are not this hook's job — Strands handles them.""" + sanitizer = ToolNameSanitizer() + agent = self._make_agent(["query"]) + event = MagicMock() + event.agent = agent + event.tool_use = {"name": "nonexistent_tool"} + event.cancel_tool = False + sanitizer._on_before_tool(event) + assert event.cancel_tool is False # not touched diff --git a/tests/unit/mcp/__init__.py b/tests/unit/mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/mcp/test_client.py b/tests/unit/mcp/test_client.py new file mode 100644 index 0000000..6389938 --- /dev/null +++ b/tests/unit/mcp/test_client.py @@ -0,0 +1,193 @@ +"""Tests for core.mcp.client — create_mcp_client.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from strands_compose.mcp.client import ( + _detect_transport, + _transport_for_server, + _transport_for_url, + create_mcp_client, +) + + +class TestCreateMcpClient: + def test_no_connection_params_raises(self): + with pytest.raises(ValueError, match="Exactly one"): + create_mcp_client() + + def test_multiple_params_raises(self): + with pytest.raises(ValueError, match="Exactly one"): + create_mcp_client(url="http://x", command=["python"]) + + @patch("strands_compose.mcp.client._make_strands_client") + @patch("strands_compose.mcp.client._transport_for_server") + def test_create_with_server(self, mock_transport, mock_make): + server = MagicMock() + mock_transport.return_value = "transport-callable" + mock_make.return_value = "client" + result = create_mcp_client(server=server) + mock_transport.assert_called_once_with(server, "streamable-http", {}) + mock_make.assert_called_once_with(transport_callable="transport-callable") + assert result == "client" + + @patch("strands_compose.mcp.client._make_strands_client") + @patch("strands_compose.mcp.client._transport_for_url") + def test_create_with_url(self, mock_transport, mock_make): + mock_transport.return_value = "transport-callable" + mock_make.return_value = "client" + result = create_mcp_client(url="http://localhost:8000/mcp") + mock_transport.assert_called_once_with("http://localhost:8000/mcp", "streamable-http", {}) + mock_make.assert_called_once_with(transport_callable="transport-callable") + assert result == "client" + + @patch("strands_compose.mcp.client._make_strands_client") + @patch("strands_compose.mcp.client.stdio_transport") + def test_create_with_command(self, mock_stdio, mock_make): + mock_stdio.return_value = "transport-callable" + mock_make.return_value = "client" + result = create_mcp_client(command=["python", "-m", "server"]) + mock_stdio.assert_called_once_with(["python", "-m", "server"]) + mock_make.assert_called_once_with(transport_callable="transport-callable") + assert result == "client" + + @patch("strands_compose.mcp.client._make_strands_client") + @patch("strands_compose.mcp.client._transport_for_url") + def test_create_forwards_extra_kwargs(self, mock_transport, mock_make): + mock_transport.return_value = "t" + mock_make.return_value = "client" + create_mcp_client(url="http://x", startup_timeout=30) + mock_make.assert_called_once_with(transport_callable="t", startup_timeout=30) + + @patch("strands_compose.mcp.client._make_strands_client") + @patch("strands_compose.mcp.client._transport_for_server") + def test_create_with_transport_options(self, mock_transport, mock_make): + server = MagicMock() + mock_transport.return_value = "t" + mock_make.return_value = "client" + opts = {"headers": {"Authorization": "Bearer tok"}, "terminate_on_close": False} + create_mcp_client(server=server, transport_options=opts) + mock_transport.assert_called_once_with(server, "streamable-http", opts) + + @patch("strands_compose.mcp.client._make_strands_client") + @patch("strands_compose.mcp.client.stdio_transport") + def test_create_command_forwards_transport_options(self, mock_stdio, mock_make): + mock_stdio.return_value = "t" + mock_make.return_value = "client" + create_mcp_client(command=["node", "server.js"], transport_options={"cwd": "/tmp"}) # nosec B108 + mock_stdio.assert_called_once_with(["node", "server.js"], cwd="/tmp") # nosec B108 + + +class TestMakeStrandsClient: + @patch("strands.tools.mcp.MCPClient") + def test_make_strands_client(self, mock_cls): + from strands_compose.mcp.client import _make_strands_client + + mock_cls.return_value = "instance" + result = _make_strands_client(transport_callable="tc", startup_timeout=10) + mock_cls.assert_called_once_with(transport_callable="tc", startup_timeout=10) + assert result == "instance" + + +class TestDetectTransport: + def test_sse_detected(self): + assert _detect_transport("http://localhost/sse") == "sse" + + def test_default_streamable_http(self): + assert _detect_transport("http://localhost/mcp") == "streamable-http" + + +class TestTransportForServer: + @patch("strands_compose.mcp.client.streamable_http_transport") + def test_streamable_http_default(self, mock_transport): + server = MagicMock() + server.url = "http://localhost:8000/mcp" + mock_transport.return_value = "transport" + result = _transport_for_server(server, None) + mock_transport.assert_called_once_with("http://localhost:8000/mcp") + assert result == "transport" + + @patch("strands_compose.mcp.client.streamable_http_transport") + def test_streamable_http_explicit(self, mock_transport): + server = MagicMock() + server.url = "http://localhost:8000/mcp" + mock_transport.return_value = "transport" + result = _transport_for_server(server, "streamable-http") + mock_transport.assert_called_once_with("http://localhost:8000/mcp") + assert result == "transport" + + @patch("strands_compose.mcp.client.sse_transport") + def test_sse_transport(self, mock_transport): + server = MagicMock() + server.url = "http://localhost:8000/sse" + mock_transport.return_value = "transport" + result = _transport_for_server(server, "sse") + mock_transport.assert_called_once_with("http://localhost:8000/sse") + assert result == "transport" + + @patch("strands_compose.mcp.client.streamable_http_transport") + def test_forwards_transport_options(self, mock_transport): + server = MagicMock() + server.url = "http://localhost:8000/mcp" + mock_transport.return_value = "transport" + opts = {"terminate_on_close": False} + result = _transport_for_server(server, "streamable-http", opts) + mock_transport.assert_called_once_with( + "http://localhost:8000/mcp", terminate_on_close=False + ) + assert result == "transport" + + def test_stdio_raises(self): + with pytest.raises(ValueError, match="not supported"): + _transport_for_server(MagicMock(), "stdio") + + def test_unknown_raises(self): + with pytest.raises(ValueError, match="Unknown transport"): + _transport_for_server(MagicMock(), "grpc") + + +class TestTransportForUrl: + @patch("strands_compose.mcp.client.streamable_http_transport") + def test_streamable_http_explicit(self, mock_transport): + mock_transport.return_value = "transport" + result = _transport_for_url("http://localhost:8000/mcp", "streamable-http") + mock_transport.assert_called_once_with("http://localhost:8000/mcp") + assert result == "transport" + + @patch("strands_compose.mcp.client.sse_transport") + def test_sse_explicit(self, mock_transport): + mock_transport.return_value = "transport" + result = _transport_for_url("http://localhost:8000/sse", "sse") + mock_transport.assert_called_once_with("http://localhost:8000/sse") + assert result == "transport" + + @patch("strands_compose.mcp.client.streamable_http_transport") + def test_auto_detect_streamable_http(self, mock_transport): + mock_transport.return_value = "transport" + result = _transport_for_url("http://localhost:8000/mcp", None) + mock_transport.assert_called_once_with("http://localhost:8000/mcp") + assert result == "transport" + + @patch("strands_compose.mcp.client.sse_transport") + def test_auto_detect_sse(self, mock_transport): + mock_transport.return_value = "transport" + result = _transport_for_url("http://localhost:8000/sse", None) + mock_transport.assert_called_once_with("http://localhost:8000/sse") + assert result == "transport" + + @patch("strands_compose.mcp.client.sse_transport") + def test_forwards_transport_options(self, mock_transport): + mock_transport.return_value = "transport" + opts = {"timeout": 30, "sse_read_timeout": 600} + result = _transport_for_url("http://localhost:8000/sse", "sse", opts) + mock_transport.assert_called_once_with( + "http://localhost:8000/sse", timeout=30, sse_read_timeout=600 + ) + assert result == "transport" + + def test_unsupported_transport_raises(self): + with pytest.raises(ValueError, match=r"requires.*sse.*streamable-http"): + _transport_for_url("http://x", "stdio") diff --git a/tests/unit/mcp/test_init.py b/tests/unit/mcp/test_init.py new file mode 100644 index 0000000..77e0c25 --- /dev/null +++ b/tests/unit/mcp/test_init.py @@ -0,0 +1,32 @@ +"""Tests for core.mcp.__init__ — explicit imports.""" + +from __future__ import annotations + +import pytest + + +class TestExplicitImports: + @pytest.mark.parametrize( + "name", + [ + "MCPLifecycle", + "create_mcp_client", + "MCPServer", + "sse_transport", + "stdio_transport", + "streamable_http_transport", + ], + ) + def test_public_symbols_importable(self, name: str) -> None: + """All public symbols are importable from strands_compose.mcp.""" + import strands_compose.mcp as mcp + + assert hasattr(mcp, name), f"{name!r} not importable" + obj = getattr(mcp, name) + assert obj is not None + + def test_unknown_attr_raises(self): + import strands_compose.mcp as mcp + + with pytest.raises(AttributeError): + getattr(mcp, "nonexistent_symbol") diff --git a/tests/unit/mcp/test_lifecycle.py b/tests/unit/mcp/test_lifecycle.py new file mode 100644 index 0000000..4a23517 --- /dev/null +++ b/tests/unit/mcp/test_lifecycle.py @@ -0,0 +1,152 @@ +"""Tests for core.mcp.lifecycle — MCPLifecycle.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from strands_compose.mcp.lifecycle import MCPLifecycle + + +class TestMCPLifecycle: + def test_add_server_and_get(self): + lc = MCPLifecycle() + server = MagicMock() + lc.add_server("pg", server) + assert lc.get_server("pg") is server + + def test_add_client_and_get(self): + lc = MCPLifecycle() + client = MagicMock() + lc.add_client("c", client) + assert lc.get_client("c") is client + + def test_duplicate_server_raises(self): + lc = MCPLifecycle() + lc.add_server("pg", MagicMock()) + with pytest.raises(ValueError, match="already registered"): + lc.add_server("pg", MagicMock()) + + def test_duplicate_client_raises(self): + lc = MCPLifecycle() + lc.add_client("c", MagicMock()) + with pytest.raises(ValueError, match="already registered"): + lc.add_client("c", MagicMock()) + + def test_get_missing_server_raises(self): + lc = MCPLifecycle() + with pytest.raises(KeyError): + lc.get_server("missing") + + def test_get_missing_client_raises(self): + lc = MCPLifecycle() + with pytest.raises(KeyError): + lc.get_client("missing") + + def test_start_starts_servers_not_clients(self): + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = True + client = MagicMock() + + lc.add_server("s", server) + lc.add_client("c", client) + lc.start() + + server.start.assert_called_once() + server.wait_ready.assert_called_once() + client.start.assert_not_called() + assert lc._started + + def test_start_raises_if_server_not_ready(self): + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = False + lc.add_server("s", server) + with pytest.raises(RuntimeError, match="did not become ready"): + lc.start() + + def test_stop_stops_clients_then_servers(self): + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = True + client = MagicMock() + + lc.add_server("s", server) + lc.add_client("c", client) + lc.start() + lc.stop() + + client.stop.assert_called_once_with(exc_type=None, exc_val=None, exc_tb=None) + server.stop.assert_called_once() + assert not lc._started + + def test_context_manager(self): + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = True + lc.add_server("s", server) + + with lc: + assert lc._started + assert not lc._started + + @pytest.mark.asyncio + async def test_async_context_manager(self): + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = True + lc.add_server("s", server) + + async with lc: + assert lc._started + server.start.assert_called_once() + assert not lc._started + server.stop.assert_called_once() + + def test_stop_without_start_is_noop(self): + lc = MCPLifecycle() + lc.add_server("s", MagicMock()) + lc.stop() # should not raise + assert not lc._started + + def test_start_idempotent(self): + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = True + lc.add_server("s", server) + lc.start() + lc.start() # second call should be no-op + server.start.assert_called_once() + + def test_stop_suppresses_client_errors(self): + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = True + client = MagicMock() + client.stop.side_effect = RuntimeError("connection lost") + lc.add_server("s", server) + lc.add_client("c", client) + lc.start() + lc.stop() # should not raise + assert not lc._started + + def test_stop_suppresses_server_errors(self): + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = True + server.stop.side_effect = RuntimeError("stop failed") + lc.add_server("s", server) + lc.start() + lc.stop() # should not raise + assert not lc._started + + def test_servers_and_clients_properties(self): + lc = MCPLifecycle() + server = MagicMock() + client = MagicMock() + lc.add_server("s", server) + lc.add_client("c", client) + assert lc.servers == {"s": server} + assert lc.clients == {"c": client} diff --git a/tests/unit/mcp/test_server.py b/tests/unit/mcp/test_server.py new file mode 100644 index 0000000..af85104 --- /dev/null +++ b/tests/unit/mcp/test_server.py @@ -0,0 +1,203 @@ +"""Tests for core.mcp.server — MCPServer abstract base class.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from strands_compose.mcp.server import MCPServer, create_mcp_server + + +class ConcreteMCPServer(MCPServer): + def _register_tools(self, mcp): + pass + + +class TestMCPServer: + def test_url_property(self): + s = ConcreteMCPServer(name="test", host="127.0.0.1", port=9000) + assert s.url == "http://127.0.0.1:9000/mcp" + + def test_not_running_initially(self): + s = ConcreteMCPServer(name="test") + assert s.is_running is False + + def test_stop_clears_state(self): + s = ConcreteMCPServer(name="test") + s._mcp = MagicMock() + s._thread = MagicMock() + s._uvicorn_server = MagicMock() + s.stop() + assert s._mcp is None + assert s._thread is None + assert s._uvicorn_server is None + + @patch("mcp.server.fastmcp.FastMCP") + def test_create_server_caches(self, mock_cls): + mock_cls.return_value = MagicMock() + s = ConcreteMCPServer(name="test") + first = s.create_server() + second = s.create_server() + assert first is second + + def test_start_sets_thread_and_uvicorn_server(self): + """start() should create both a thread and a uvicorn.Server for HTTP transports.""" + s = ConcreteMCPServer(name="test", port=19999) + mock_mcp = MagicMock() + mock_mcp.streamable_http_app.return_value = MagicMock() + + with ( + patch.object(s, "create_server", return_value=mock_mcp), + patch("uvicorn.Config") as mock_config_cls, + patch("uvicorn.Server") as mock_server_cls, + ): + mock_config_cls.return_value = MagicMock() + mock_server = MagicMock() + mock_server_cls.return_value = mock_server + + s.start() + assert s._thread is not None + assert s._uvicorn_server is mock_server + s.stop() + + def test_start_idempotent_when_running(self): + s = ConcreteMCPServer(name="test") + s._thread = MagicMock() + s._thread.is_alive.return_value = True + s.start() # should not create a new thread + + # -- stop() graceful shutdown ----------------------------------- # + + def test_stop_sets_should_exit_on_uvicorn_server(self): + """stop() should signal should_exit on the uvicorn.Server.""" + s = ConcreteMCPServer(name="test") + mock_uv = MagicMock() + mock_uv.should_exit = False + mock_uv.force_exit = False + s._uvicorn_server = mock_uv + + mock_thread = MagicMock() + # Thread is alive initially, exits after join, final check confirms dead + mock_thread.is_alive.side_effect = [True, False, False] + s._thread = mock_thread + + s.stop() + assert mock_uv.should_exit is True + assert mock_uv.force_exit is False # should not escalate + + def test_stop_escalates_to_force_exit(self): + """stop() should set force_exit if the thread is still alive after STOP_TIMEOUT.""" + s = ConcreteMCPServer(name="test") + mock_uv = MagicMock() + mock_uv.should_exit = False + mock_uv.force_exit = False + s._uvicorn_server = mock_uv + + mock_thread = MagicMock() + # Thread is alive during the first join, then exits after force_exit + mock_thread.is_alive.side_effect = [True, True, False] + s._thread = mock_thread + + s.stop() + assert mock_uv.should_exit is True + assert mock_uv.force_exit is True + # join called twice: once for graceful, once for force + assert mock_thread.join.call_count == 2 + + def test_stop_warns_if_thread_wont_die(self): + """stop() should log a warning if the thread refuses to exit.""" + s = ConcreteMCPServer(name="test") + mock_uv = MagicMock() + s._uvicorn_server = mock_uv + + mock_thread = MagicMock() + mock_thread.is_alive.return_value = True # never exits + s._thread = mock_thread + + with patch("strands_compose.mcp.server.logger") as mock_logger: + s.stop() + mock_logger.warning.assert_called_once() + assert "did not stop" in mock_logger.warning.call_args[0][0] + + # -- _get_asgi_app ---------------------------------------------- # + + def test_get_asgi_app_streamable_http(self): + s = ConcreteMCPServer(name="test", transport="streamable-http") + mock_mcp = MagicMock() + mock_mcp.streamable_http_app.return_value = "streamable_app" + assert s._get_asgi_app(mock_mcp) == "streamable_app" + + def test_get_asgi_app_sse(self): + s = ConcreteMCPServer(name="test", transport="sse") + mock_mcp = MagicMock() + mock_mcp.sse_app.return_value = "sse_app" + assert s._get_asgi_app(mock_mcp) == "sse_app" + + def test_get_asgi_app_raises_for_unsupported_transport(self): + """_get_asgi_app should raise ValueError for unsupported transports like stdio.""" + s = ConcreteMCPServer(name="test") + s.transport = "stdio" # force invalid value + mock_mcp = MagicMock() + with pytest.raises(ValueError, match="Unsupported server transport.*stdio"): + s._get_asgi_app(mock_mcp) + + # -- class-level timeout attributes ----------------------------- # + + def test_default_timeouts(self): + s = ConcreteMCPServer(name="test") + assert s.STOP_TIMEOUT == 5 + assert s.STOP_FORCE_TIMEOUT == 2 + + +class TestCreateMcpServer: + """Tests for the create_mcp_server factory function.""" + + def test_returns_mcp_server_instance(self): + server = create_mcp_server(name="test", tools=[]) + assert isinstance(server, MCPServer) + + def test_name_and_port_forwarded(self): + server = create_mcp_server(name="my-srv", tools=[], port=9999, host="0.0.0.0") + assert server.name == "my-srv" + assert server.port == 9999 + assert server.host == "0.0.0.0" + + @patch("mcp.server.fastmcp.FastMCP") + def test_tools_registered_on_create_server(self, mock_cls): + mock_mcp = MagicMock() + mock_cls.return_value = mock_mcp + + def tool_a() -> str: + return "a" + + def tool_b(x: int) -> int: + return x + + server = create_mcp_server(name="test", tools=[tool_a, tool_b]) + server.create_server() + + # Each tool should be registered via mcp.tool()(fn) + assert mock_mcp.tool.return_value.call_count == 2 + calls = mock_mcp.tool.return_value.call_args_list + assert calls[0].args[0] is tool_a + assert calls[1].args[0] is tool_b + + @patch("mcp.server.fastmcp.FastMCP") + def test_empty_tools_list(self, mock_cls): + mock_mcp = MagicMock() + mock_cls.return_value = mock_mcp + + server = create_mcp_server(name="empty", tools=[]) + server.create_server() + + mock_mcp.tool.return_value.assert_not_called() + + def test_server_params_forwarded(self): + params = {"custom_key": "custom_val"} + server = create_mcp_server(name="test", tools=[], server_params=params) + assert server.server_params == params + + def test_url_property(self): + server = create_mcp_server(name="test", tools=[], host="localhost", port=5555) + assert server.url == "http://localhost:5555/mcp" diff --git a/tests/unit/mcp/test_transports.py b/tests/unit/mcp/test_transports.py new file mode 100644 index 0000000..a7b8f1e --- /dev/null +++ b/tests/unit/mcp/test_transports.py @@ -0,0 +1,301 @@ +"""Tests for core.mcp.transports — transport factory functions.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from strands_compose.mcp.transports import ( + sse_transport, + stdio_transport, + streamable_http_transport, +) + +# =========================================================================== +# stdio_transport +# =========================================================================== + + +class TestStdioTransport: + """Tests for stdio_transport factory.""" + + def test_empty_command_raises(self) -> None: + """Empty command list is rejected with a clear error.""" + with pytest.raises(ValueError, match="non-empty"): + stdio_transport([]) + + def test_returns_callable(self) -> None: + """Factory returns a callable.""" + t = stdio_transport(["python", "-m", "server"]) + assert callable(t) + + @patch("mcp.client.stdio.stdio_client") + @patch("mcp.client.stdio.StdioServerParameters") + def test_splits_command_into_command_and_args( + self, mock_params_cls: MagicMock, mock_stdio_client: MagicMock + ) -> None: + """command[0] becomes 'command', command[1:] becomes 'args'.""" + factory = stdio_transport(["node", "--inspect", "server.js"]) + factory() + + mock_params_cls.assert_called_once_with( + command="node", + args=["--inspect", "server.js"], + env=None, + cwd=None, + encoding="utf-8", + encoding_error_handler="strict", + ) + + @patch("mcp.client.stdio.stdio_client") + @patch("mcp.client.stdio.StdioServerParameters") + def test_single_element_command_yields_empty_args( + self, mock_params_cls: MagicMock, mock_stdio_client: MagicMock + ) -> None: + """A single-element command produces an empty args list.""" + factory = stdio_transport(["myserver"]) + factory() + + mock_params_cls.assert_called_once_with( + command="myserver", + args=[], + env=None, + cwd=None, + encoding="utf-8", + encoding_error_handler="strict", + ) + + @patch("mcp.client.stdio.stdio_client") + @patch("mcp.client.stdio.StdioServerParameters") + def test_passes_all_optional_params( + self, mock_params_cls: MagicMock, mock_stdio_client: MagicMock + ) -> None: + """env, cwd, encoding, and encoding_error_handler are forwarded.""" + factory = stdio_transport( + ["python", "srv.py"], + env={"KEY": "val"}, + cwd="/tmp", + encoding="ascii", + encoding_error_handler="replace", + ) + factory() + + mock_params_cls.assert_called_once_with( + command="python", + args=["srv.py"], + env={"KEY": "val"}, + cwd="/tmp", + encoding="ascii", + encoding_error_handler="replace", + ) + + @patch("mcp.client.stdio.stdio_client") + @patch("mcp.client.stdio.StdioServerParameters") + def test_defensive_copy_of_command( + self, mock_params_cls: MagicMock, mock_stdio_client: MagicMock + ) -> None: + """Mutating the original command list after creation has no effect.""" + cmd = ["python", "-m", "server"] + factory = stdio_transport(cmd) + cmd.append("--extra-flag") + factory() + + _, kwargs = mock_params_cls.call_args + assert kwargs["args"] == ["-m", "server"] # no "--extra-flag" + + @patch("mcp.client.stdio.stdio_client") + @patch("mcp.client.stdio.StdioServerParameters") + def test_defensive_copy_of_env( + self, mock_params_cls: MagicMock, mock_stdio_client: MagicMock + ) -> None: + """Mutating the original env dict after creation has no effect.""" + env = {"A": "1"} + factory = stdio_transport(["python"], env=env) + env["B"] = "2" + factory() + + _, kwargs = mock_params_cls.call_args + assert kwargs["env"] == {"A": "1"} # no "B" + + +# =========================================================================== +# sse_transport +# =========================================================================== + + +class TestSseTransport: + """Tests for sse_transport factory.""" + + def test_empty_url_raises(self) -> None: + """Empty URL is rejected.""" + with pytest.raises(ValueError, match="non-empty"): + sse_transport("") + + def test_returns_callable(self) -> None: + """Factory returns a callable.""" + t = sse_transport("http://localhost:8000/sse") + assert callable(t) + + @patch("mcp.client.sse.sse_client") + def test_passes_default_kwargs(self, mock_sse_client: MagicMock) -> None: + """Default timeout/sse_read_timeout are forwarded; auth excluded.""" + factory = sse_transport("http://host/sse") + factory() + + mock_sse_client.assert_called_once_with( + url="http://host/sse", + headers={}, + timeout=5, + sse_read_timeout=300, + ) + + @patch("mcp.client.sse.sse_client") + def test_auth_included_only_when_provided(self, mock_sse_client: MagicMock) -> None: + """auth kwarg is only passed to sse_client when explicitly set.""" + auth_obj = MagicMock() + factory = sse_transport("http://host/sse", auth=auth_obj) + factory() + + kwargs = mock_sse_client.call_args[1] + assert kwargs["auth"] is auth_obj + + @patch("mcp.client.sse.sse_client") + def test_auth_excluded_when_none(self, mock_sse_client: MagicMock) -> None: + """auth kwarg is absent from the call when not provided.""" + factory = sse_transport("http://host/sse") + factory() + + kwargs = mock_sse_client.call_args[1] + assert "auth" not in kwargs + + @patch("mcp.client.sse.sse_client") + def test_httpx_client_factory_included_only_when_provided( + self, mock_sse_client: MagicMock + ) -> None: + """httpx_client_factory kwarg is only passed when explicitly set.""" + client_factory = MagicMock() + factory = sse_transport("http://host/sse", httpx_client_factory=client_factory) + factory() + + kwargs = mock_sse_client.call_args[1] + assert kwargs["httpx_client_factory"] is client_factory + + @patch("mcp.client.sse.sse_client") + def test_httpx_client_factory_excluded_when_none(self, mock_sse_client: MagicMock) -> None: + """httpx_client_factory kwarg is absent when not provided.""" + factory = sse_transport("http://host/sse") + factory() + + kwargs = mock_sse_client.call_args[1] + assert "httpx_client_factory" not in kwargs + + @patch("mcp.client.sse.sse_client") + def test_custom_headers_and_timeouts(self, mock_sse_client: MagicMock) -> None: + """Custom headers and timeouts are forwarded.""" + factory = sse_transport( + "http://host/sse", + headers={"Authorization": "Bearer tok"}, + timeout=10, + sse_read_timeout=60, + ) + factory() + + mock_sse_client.assert_called_once_with( + url="http://host/sse", + headers={"Authorization": "Bearer tok"}, + timeout=10, + sse_read_timeout=60, + ) + + +# =========================================================================== +# streamable_http_transport +# =========================================================================== + + +class TestStreamableHttpTransport: + """Tests for streamable_http_transport factory.""" + + def test_empty_url_raises(self) -> None: + """Empty URL is rejected.""" + with pytest.raises(ValueError, match="non-empty"): + streamable_http_transport("") + + def test_returns_callable(self) -> None: + """Factory returns a callable.""" + t = streamable_http_transport("http://localhost:8000/mcp") + assert callable(t) + + @patch("mcp.client.streamable_http.streamable_http_client") + def test_bare_call_without_headers_or_client(self, mock_client: MagicMock) -> None: + """No headers, no http_client → bare call with url and terminate_on_close.""" + factory = streamable_http_transport("http://host/mcp") + factory() + + mock_client.assert_called_once_with(url="http://host/mcp", terminate_on_close=True) + + @patch("mcp.client.streamable_http.streamable_http_client") + def test_http_client_passed_directly(self, mock_client: MagicMock) -> None: + """Pre-configured http_client is forwarded as-is.""" + custom_client = MagicMock() + factory = streamable_http_transport("http://host/mcp", http_client=custom_client) + factory() + + mock_client.assert_called_once_with( + url="http://host/mcp", + http_client=custom_client, + terminate_on_close=True, + ) + + @patch("mcp.client.streamable_http.streamable_http_client") + def test_headers_ignored_when_http_client_provided(self, mock_client: MagicMock) -> None: + """When http_client is given, headers param is silently ignored.""" + custom_client = MagicMock() + factory = streamable_http_transport( + "http://host/mcp", + headers={"X-Custom": "val"}, + http_client=custom_client, + ) + factory() + + # The call should use http_client, NOT create a new one from headers + call_kwargs = mock_client.call_args[1] + assert call_kwargs["http_client"] is custom_client + + @patch("httpx.AsyncClient") + @patch("mcp.client.streamable_http.streamable_http_client") + def test_headers_create_httpx_client( + self, mock_client: MagicMock, mock_async_client_cls: MagicMock + ) -> None: + """Headers without http_client → creates httpx.AsyncClient with those headers.""" + factory = streamable_http_transport( + "http://host/mcp", headers={"Authorization": "Bearer tok"} + ) + factory() + + mock_async_client_cls.assert_called_once_with(headers={"Authorization": "Bearer tok"}) + call_kwargs = mock_client.call_args[1] + assert call_kwargs["http_client"] is mock_async_client_cls.return_value + + @patch("mcp.client.streamable_http.streamable_http_client") + def test_terminate_on_close_false(self, mock_client: MagicMock) -> None: + """terminate_on_close=False is forwarded.""" + factory = streamable_http_transport("http://host/mcp", terminate_on_close=False) + factory() + + call_kwargs = mock_client.call_args[1] + assert call_kwargs["terminate_on_close"] is False + + @patch("mcp.client.streamable_http.streamable_http_client") + def test_defensive_copy_of_headers(self, mock_client: MagicMock) -> None: + """Mutating the original headers dict after creation has no effect.""" + hdrs = {"X-Key": "original"} + factory = streamable_http_transport("http://host/mcp", headers=hdrs) + hdrs["X-Key"] = "mutated" + hdrs["X-New"] = "injected" + + # We need to trigger the headers branch, so mock httpx too + with patch("httpx.AsyncClient") as mock_httpx: + factory() + mock_httpx.assert_called_once_with(headers={"X-Key": "original"}) diff --git a/tests/unit/models/__init__.py b/tests/unit/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/models/test_models.py b/tests/unit/models/test_models.py new file mode 100644 index 0000000..30b5269 --- /dev/null +++ b/tests/unit/models/test_models.py @@ -0,0 +1,61 @@ +"""Tests for core.models — create_model, create_bedrock_model, create_ollama_model.""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +import pytest + +from strands_compose.models import create_model + + +class TestCreateModel: + @patch("strands.models.bedrock.BedrockModel") + def test_bedrock_dispatch(self, mock_bedrock): + create_model("bedrock", "us.amazon.nova-pro-v1:0") + mock_bedrock.assert_called_once() + + @patch("strands.models.ollama.OllamaModel") + def test_ollama_dispatch(self, mock_ollama): + create_model("ollama", "llama3") + mock_ollama.assert_called_once() + + def test_unknown_provider_raises(self): + with pytest.raises(ValueError, match="Unknown model provider"): + create_model("gpt", "gpt-4") + + @patch("strands.models.bedrock.BedrockModel") + def test_case_insensitive(self, mock_bedrock: MagicMock) -> None: + create_model("Bedrock", "model-id") + mock_bedrock.assert_called_once() + + +class TestFriendlyImportErrors: + """Verify that missing optional provider packages raise friendly ImportErrors.""" + + def test_ollama_missing_raises_friendly_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """ImportError for ollama includes the correct pip install command.""" + monkeypatch.setitem(sys.modules, "strands.models.ollama", None) + with pytest.raises(ImportError, match=r"pip install strands-compose\[ollama\]"): + create_model("ollama", "llama3") + + def test_openai_missing_raises_friendly_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """ImportError for openai includes the correct pip install command.""" + monkeypatch.setitem(sys.modules, "strands.models.openai", None) + with pytest.raises(ImportError, match=r"pip install strands-compose\[openai\]"): + create_model("openai", "gpt-4") + + def test_gemini_missing_raises_friendly_error(self, monkeypatch: pytest.MonkeyPatch) -> None: + """ImportError for gemini includes the correct pip install command.""" + monkeypatch.setitem(sys.modules, "strands.models.gemini", None) + with pytest.raises(ImportError, match=r"pip install strands-compose\[gemini\]"): + create_model("gemini", "gemini-pro") + + def test_ollama_error_message_contains_install_hint( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Ollama ImportError message also mentions strands-agents fallback install.""" + monkeypatch.setitem(sys.modules, "strands.models.ollama", None) + with pytest.raises(ImportError, match=r"strands-agents\[ollama\]"): + create_model("ollama", "llama3") diff --git a/tests/unit/renderers/__init__.py b/tests/unit/renderers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/renderers/test_ansi.py b/tests/unit/renderers/test_ansi.py new file mode 100644 index 0000000..f0962e9 --- /dev/null +++ b/tests/unit/renderers/test_ansi.py @@ -0,0 +1,173 @@ +"""Tests for the AnsiRenderer — zero-dependency ANSI escape-code renderer.""" + +from __future__ import annotations + +import io + +from strands_compose.renderers import AnsiRenderer +from strands_compose.types import EventType +from strands_compose.wire import StreamEvent + +AGENT = "test-agent" + + +def _event(type_: str, **data: object) -> StreamEvent: + """Build a StreamEvent with the given type and payload.""" + return StreamEvent(type=type_, agent_name=AGENT, data=dict(data)) + + +class TestAnsiRenderer: + """AnsiRenderer renders all event types to a text stream.""" + + def _renderer(self) -> tuple[AnsiRenderer, io.StringIO]: + buf = io.StringIO() + return AnsiRenderer(file=buf), buf + + # -- Token streaming --------------------------------------------------- + + def test_token_written_inline(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.TOKEN, text="hello")) + output = buf.getvalue() + # Separator line is printed first, then the token text + assert "RESPONDING" in output + assert output.endswith("hello") + + def test_consecutive_tokens_concatenate(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.TOKEN, text="he")) + r.render(_event(EventType.TOKEN, text="llo")) + assert buf.getvalue().endswith("hello") + + def test_flush_terminates_token_stream(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.TOKEN, text="hello")) + r.flush() + assert buf.getvalue().endswith("\n") + + def test_flush_is_noop_when_not_in_tokens(self) -> None: + r, buf = self._renderer() + r.flush() + assert buf.getvalue() == "" + + # -- Structured events break the token line ---------------------------- + + def test_agent_start_breaks_token_stream(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.TOKEN, text="tok")) + r.render(_event(EventType.AGENT_START)) + # Should contain a newline between "tok" and the agent_start line. + output = buf.getvalue() + assert "tok\n" in output + assert AGENT in output + + # -- All event types produce output (or are intentionally silent) ------ + + def test_agent_start(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.AGENT_START)) + assert AGENT in buf.getvalue() + assert "starting" in buf.getvalue() + + def test_tool_start(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.TOOL_START, tool_name="search", tool_input={"q": "x"})) + output = buf.getvalue() + assert "search" in output + assert "⚙" in output + + def test_tool_end_success(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.TOOL_END, status="success")) + assert "✓" in buf.getvalue() + + def test_tool_end_error(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.TOOL_END, status="error", error="timeout")) + output = buf.getvalue() + assert "✗" in output + assert "timeout" in output + + def test_complete(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.COMPLETE, usage={"input_tokens": 42, "output_tokens": 80})) + output = buf.getvalue() + assert "complete" in output + assert "42" in output + assert "80" in output + + def test_error(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.ERROR, message="something broke")) + output = buf.getvalue() + assert "ERROR" in output + assert "something broke" in output + + def test_node_start(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.NODE_START, node_id="n1")) + assert "n1" in buf.getvalue() + + def test_node_stop(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.NODE_STOP, node_id="n1")) + assert "n1" in buf.getvalue() + + def test_handoff(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.HANDOFF, to_node_ids=["n2", "n3"])) + output = buf.getvalue() + assert "n2" in output + assert "n3" in output + + def test_multiagent_start(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.MULTIAGENT_START, multiagent_type="graph")) + assert "graph" in buf.getvalue() + + def test_multiagent_complete(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.MULTIAGENT_COMPLETE, multiagent_type="graph")) + assert "graph" in buf.getvalue() + + def test_reasoning_is_displayed(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.REASONING, text="thinking…")) + output = buf.getvalue() + assert "REASONING" in output + assert "thinking…" in output + + def test_reasoning_then_token_shows_both_separators(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.REASONING, text="hmm")) + r.render(_event(EventType.TOKEN, text="answer")) + output = buf.getvalue() + assert "REASONING" in output + assert "RESPONDING" in output + assert "hmm" in output + assert "answer" in output + + def test_separator_not_repeated_for_same_mode(self) -> None: + r, buf = self._renderer() + r.render(_event(EventType.TOKEN, text="a")) + r.render(_event(EventType.TOKEN, text="b")) + output = buf.getvalue() + assert output.count("RESPONDING") == 1 + + def test_unknown_event_is_silent(self) -> None: + r, buf = self._renderer() + r.render(_event("custom_event", info="x")) + assert buf.getvalue() == "" + + # -- State transitions ------------------------------------------------- + + def test_structured_event_after_tokens_inserts_newline(self) -> None: + """Any non-token event must break the inline token stream.""" + r, buf = self._renderer() + r.render(_event(EventType.TOKEN, text="partial")) + r.render(_event(EventType.COMPLETE, usage={})) + output = buf.getvalue() + # "partial" followed by "\n" followed by the complete line + idx_partial = output.index("partial") + idx_newline = output.index("\n", idx_partial) + assert idx_newline == idx_partial + len("partial") diff --git a/tests/unit/renderers/test_base.py b/tests/unit/renderers/test_base.py new file mode 100644 index 0000000..dedffbd --- /dev/null +++ b/tests/unit/renderers/test_base.py @@ -0,0 +1,15 @@ +"""Tests for the EventRenderer abstract base class.""" + +from __future__ import annotations + +import pytest + +from strands_compose.renderers.base import EventRenderer + + +class TestEventRendererABC: + """EventRenderer cannot be instantiated directly.""" + + def test_is_abstract(self) -> None: + with pytest.raises(TypeError): + EventRenderer() diff --git a/tests/unit/startup/__init__.py b/tests/unit/startup/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/startup/test_report.py b/tests/unit/startup/test_report.py new file mode 100644 index 0000000..f737eaa --- /dev/null +++ b/tests/unit/startup/test_report.py @@ -0,0 +1,102 @@ +"""Tests for core.startup.report — CheckResult, StartupReport.""" + +from __future__ import annotations + +import pytest + +from strands_compose.startup.report import CheckResult, StartupError, StartupReport + + +class TestCheckResult: + def test_passed_factory(self): + r = CheckResult.passed("network", "mcp:pg", "Connected") + assert r.ok is True + assert r.severity == "info" + + def test_warn_factory(self): + r = CheckResult.warn("runtime", "model", "Slow", hint="Check latency") + assert r.ok is False + assert r.severity == "warning" + assert r.hint == "Check latency" + + def test_critical_factory(self): + r = CheckResult.critical("network", "mcp:pg", "Unreachable") + assert r.ok is False + assert r.severity == "critical" + + def test_str_passed(self): + r = CheckResult.passed("network", "mcp:pg", "OK") + assert "✓" in str(r) + + def test_str_critical(self): + r = CheckResult.critical("network", "mcp:pg", "Fail", hint="Fix it") + s = str(r) + assert "✗" in s + assert "Fix it" in s + + +class TestStartupReport: + def test_ok_when_no_critical(self): + report = StartupReport(checks=[CheckResult.passed("net", "s", "ok")]) + assert report.ok + + def test_not_ok_when_critical(self): + report = StartupReport(checks=[CheckResult.critical("net", "s", "bad")]) + assert not report.ok + + def test_raise_if_critical(self): + report = StartupReport(checks=[CheckResult.critical("net", "s", "boom")]) + with pytest.raises(StartupError): + report.raise_if_critical() + + def test_no_raise_when_ok(self): + report = StartupReport(checks=[CheckResult.passed("net", "s", "ok")]) + report.raise_if_critical() # should not raise + + def test_warnings_property(self): + report = StartupReport( + checks=[ + CheckResult.passed("net", "a", "ok"), + CheckResult.warn("net", "b", "slow"), + ] + ) + assert len(report.warnings) == 1 + + def test_critical_checks_property(self): + report = StartupReport( + checks=[ + CheckResult.passed("net", "a", "ok"), + CheckResult.critical("net", "b", "fail"), + ] + ) + assert len(report.critical_checks) == 1 + + def test_passed_checks_property(self): + report = StartupReport( + checks=[ + CheckResult.passed("net", "a", "ok"), + CheckResult.critical("net", "b", "fail"), + ] + ) + assert len(report.passed_checks) == 1 + + def test_print_summary(self): + report = StartupReport( + checks=[ + CheckResult.passed("net", "a", "ok"), + CheckResult.warn("net", "b", "slow"), + ] + ) + report.print_summary() # should not raise + + def test_print_summary_verbose(self): + report = StartupReport(checks=[CheckResult.passed("net", "a", "ok")]) + report.print_summary(verbose=True) # should not raise + + +class TestStartupError: + def test_message_format(self): + report = StartupReport(checks=[CheckResult.critical("net", "s", "boom")]) + err = StartupError(report) + assert "boom" in str(err) + assert err.report is report diff --git a/tests/unit/startup/test_validator.py b/tests/unit/startup/test_validator.py new file mode 100644 index 0000000..c8a51fa --- /dev/null +++ b/tests/unit/startup/test_validator.py @@ -0,0 +1,133 @@ +"""Tests for core.startup.validator — validate, probe_http_health, _check_mcp_client.""" + +from __future__ import annotations + +import http.client +import urllib.error +from io import BytesIO +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from strands_compose.startup.validator import ( + _check_mcp_client, + probe_http_health, + validate_mcp, +) + + +@pytest.mark.asyncio +class TestProbeHttpHealth: + async def test_unreachable_returns_critical(self): + result = await probe_http_health("test", "http://127.0.0.1:1") + assert result.ok is False + assert result.severity == "critical" + + @patch("urllib.request.urlopen") + async def test_http_200_returns_passed(self, mock_urlopen): + mock_resp = MagicMock() + mock_resp.status = 200 + mock_urlopen.return_value = mock_resp + result = await probe_http_health("test", "http://localhost:8000") + assert result.ok is True + assert "HTTP 200" in result.message + + @patch("urllib.request.urlopen") + async def test_http_404_returns_passed(self, mock_urlopen): + """4xx responses mean the server is reachable — should pass.""" + exc = urllib.error.HTTPError( + "http://localhost:8000", + 404, + "Not Found", + http.client.HTTPMessage(), + BytesIO(b""), + ) + mock_urlopen.side_effect = exc + result = await probe_http_health("test", "http://localhost:8000") + assert result.ok is True + assert "HTTP 404" in result.message + + @patch("urllib.request.urlopen") + async def test_http_406_returns_passed(self, mock_urlopen): + """406 from MCP endpoint that only accepts POST — still reachable.""" + exc = urllib.error.HTTPError( + "http://localhost:8000", + 406, + "Not Acceptable", + http.client.HTTPMessage(), + BytesIO(b""), + ) + mock_urlopen.side_effect = exc + result = await probe_http_health("test", "http://localhost:8000") + assert result.ok is True + assert "HTTP 406" in result.message + + @patch("urllib.request.urlopen") + async def test_http_500_returns_warning(self, mock_urlopen): + exc = urllib.error.HTTPError( + "http://localhost:8000", + 500, + "Internal Server Error", + http.client.HTTPMessage(), + BytesIO(b""), + ) + mock_urlopen.side_effect = exc + result = await probe_http_health("test", "http://localhost:8000") + assert result.ok is False + assert result.severity == "warning" + assert "HTTP 500" in result.message + + @patch("urllib.request.urlopen") + async def test_http_503_returns_warning(self, mock_urlopen): + exc = urllib.error.HTTPError( + "http://localhost:8000", + 503, + "Service Unavailable", + http.client.HTTPMessage(), + BytesIO(b""), + ) + mock_urlopen.side_effect = exc + result = await probe_http_health("test", "http://localhost:8000") + assert result.ok is False + assert result.severity == "warning" + + +@pytest.mark.asyncio +class TestCheckMcpClient: + async def test_client_tools_loaded(self): + client = AsyncMock() + client.load_tools = AsyncMock(return_value=[MagicMock()]) + result = await _check_mcp_client("test", client) + assert result.ok is True + + async def test_client_no_tools_still_passes(self): + client = AsyncMock() + client.load_tools = AsyncMock(return_value=None) + result = await _check_mcp_client("test", client) + assert result.ok is True + + async def test_client_raises_returns_warning(self): + client = AsyncMock() + client.load_tools = AsyncMock(side_effect=RuntimeError("fail")) + result = await _check_mcp_client("test", client) + assert result.severity == "warning" + + +@pytest.mark.asyncio +class TestValidate: + async def test_empty_lifecycle(self): + resolved = MagicMock() + resolved.mcp_lifecycle.servers = {} + resolved.mcp_lifecycle.clients = {} + report = await validate_mcp(resolved) + assert report.ok is True + + async def test_client_checks_included(self): + resolved = MagicMock() + resolved.mcp_lifecycle.servers = {} + client = AsyncMock() + client.load_tools = AsyncMock(return_value=[MagicMock()]) + resolved.mcp_lifecycle.clients = {"c1": client} + report = await validate_mcp(resolved) + assert report.ok is True + assert len(report.checks) == 1 diff --git a/tests/unit/test_concurrency.py b/tests/unit/test_concurrency.py new file mode 100644 index 0000000..85ced06 --- /dev/null +++ b/tests/unit/test_concurrency.py @@ -0,0 +1,176 @@ +"""Concurrent access tests for EventQueue and MCPLifecycle (R6). + +Validates thread-safety and async behavior under concurrent load. +""" + +from __future__ import annotations + +import asyncio +import threading +from unittest.mock import MagicMock + +import pytest + +from strands_compose.mcp.lifecycle import MCPLifecycle +from strands_compose.wire import EventQueue, StreamEvent + +# --------------------------------------------------------------------------- +# EventQueue concurrency tests +# --------------------------------------------------------------------------- + + +class TestEventQueueConcurrency: + """Validate EventQueue under concurrent access patterns.""" + + @pytest.mark.asyncio + async def test_multiple_producers_single_consumer(self) -> None: + """Multiple threads can put events while one async consumer reads them.""" + queue = asyncio.Queue(maxsize=100) + eq = EventQueue(queue) + num_events = 50 + received: list[StreamEvent] = [] + + def _producer(start: int, count: int) -> None: + """Put events from a background thread using put_event (thread-safe).""" + for i in range(count): + event = StreamEvent( + type="token", + agent_name=f"producer-{start}", + data={"index": start + i}, + ) + eq.put_event(event) + + threads = [] + for t_idx in range(5): + t = threading.Thread(target=_producer, args=(t_idx * 10, 10)) + threads.append(t) + + # Start all producers + for t in threads: + t.start() + for t in threads: + t.join() + + # Signal end + await eq.close() + + # Consume + while True: + event = await eq.get() + if event is None: + break + received.append(event) + + assert len(received) == num_events + + @pytest.mark.asyncio + async def test_put_event_thread_safe(self) -> None: + """put_event can be called from threads safely.""" + queue = asyncio.Queue(maxsize=100) + eq = EventQueue(queue) + event = StreamEvent(type="token", agent_name="test", data={"text": "hi"}) + + eq.put_event(event) + result = await eq.get() + assert result is event + + @pytest.mark.asyncio + async def test_queue_full_drops_event(self) -> None: + """When queue is full, events are dropped with a warning (not raised).""" + queue = asyncio.Queue(maxsize=1) + eq = EventQueue(queue) + + # Fill the queue + event1 = StreamEvent(type="token", agent_name="a", data={}) + event2 = StreamEvent(type="token", agent_name="a", data={}) + eq._put(event1) + eq._put(event2) # Should be dropped, not raise + + result = await eq.get() + assert result is event1 + + @pytest.mark.asyncio + async def test_close_then_get_returns_none(self) -> None: + """Closing the queue causes get to return None.""" + queue = asyncio.Queue() + eq = EventQueue(queue) + await eq.close() + result = await eq.get() + assert result is None + + @pytest.mark.asyncio + async def test_flush_during_production(self) -> None: + """flush() clears all pending events.""" + queue = asyncio.Queue(maxsize=100) + eq = EventQueue(queue) + + for i in range(10): + eq._put(StreamEvent(type="token", agent_name="a", data={"i": i})) + + eq.flush() + assert queue.empty() + + +# --------------------------------------------------------------------------- +# MCPLifecycle concurrency tests +# --------------------------------------------------------------------------- + + +class TestMCPLifecycleConcurrency: + """Validate MCPLifecycle thread-safety and idempotent operations.""" + + def test_concurrent_start_is_idempotent(self) -> None: + """Calling start() from multiple threads results in only one server.start().""" + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = True + lc.add_server("s", server) + + threads = [] + for _ in range(5): + t = threading.Thread(target=lc.start) + threads.append(t) + + for t in threads: + t.start() + for t in threads: + t.join() + + # Server.start should only be called once due to idempotency + server.start.assert_called_once() + + def test_stop_after_start_from_different_thread(self) -> None: + """start() in one thread, stop() in another — no deadlocks.""" + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = True + lc.add_server("s", server) + + lc.start() + + stop_thread = threading.Thread(target=lc.stop) + stop_thread.start() + stop_thread.join(timeout=5) + + assert not stop_thread.is_alive(), "stop() should complete without deadlock" + assert not lc._started + + def test_concurrent_stop_is_safe(self) -> None: + """Multiple stop() calls from different threads don't raise.""" + lc = MCPLifecycle() + server = MagicMock() + server.wait_ready.return_value = True + lc.add_server("s", server) + lc.start() + + threads = [] + for _ in range(5): + t = threading.Thread(target=lc.stop) + threads.append(t) + + for t in threads: + t.start() + for t in threads: + t.join() + + assert not lc._started diff --git a/tests/unit/test_event_queue.py b/tests/unit/test_event_queue.py new file mode 100644 index 0000000..e96f841 --- /dev/null +++ b/tests/unit/test_event_queue.py @@ -0,0 +1,117 @@ +"""Tests for strands_compose.wire — EventQueue and make_event_queue.""" + +from __future__ import annotations + +import asyncio +from unittest.mock import MagicMock + +import pytest +from strands.multiagent import Swarm + +from strands_compose.hooks import EventPublisher +from strands_compose.wire import EventQueue, StreamEvent, make_event_queue + +# --------------------------------------------------------------------------- +# EventQueue +# --------------------------------------------------------------------------- + + +class TestEventQueue: + def test_get_returns_event(self): + queue = asyncio.Queue() + eq = EventQueue(queue) + event = MagicMock(spec=StreamEvent) + queue.put_nowait(event) + + result = asyncio.get_event_loop().run_until_complete(eq.get()) + assert result is event + + def test_close_then_get_returns_none(self): + async def _run(): + queue = asyncio.Queue() + eq = EventQueue(queue) + await eq.close() + return await eq.get() + + assert asyncio.get_event_loop().run_until_complete(_run()) is None + + def test_flush_clears_stale_events(self): + queue = asyncio.Queue() + eq = EventQueue(queue) + for _ in range(3): + queue.put_nowait(MagicMock(spec=StreamEvent)) + + eq.flush() + assert queue.empty() + + def test_flush_empty_queue_is_noop(self): + eq = EventQueue(asyncio.Queue()) + eq.flush() # should not raise + + +# --------------------------------------------------------------------------- +# make_event_queue +# --------------------------------------------------------------------------- + + +def _make_agent(name: str = "agent") -> MagicMock: + """Return a minimal mock Agent.""" + agent = MagicMock() + agent.agent_id = name + agent.hooks = MagicMock() + agent.hooks._registered_callbacks = {} + return agent + + +class TestMakeEventQueue: + def test_returns_event_queue(self): + agent = _make_agent() + eq = make_event_queue({"a": agent}) + assert isinstance(eq, EventQueue) + + def test_hooks_added_to_each_agent(self): + a, b = _make_agent("a"), _make_agent("b") + make_event_queue({"a": a, "b": b}) + assert a.hooks.add_hook.called + assert b.hooks.add_hook.called + + def test_callback_handler_set_on_agent(self): + agent = _make_agent() + make_event_queue({"a": agent}) + assert agent.callback_handler is not None + + def test_wires_orchestrator(self): + agent = _make_agent() + orch = MagicMock(spec=Swarm) + orch.id = "orch" + orch.hooks = MagicMock() + make_event_queue({"a": agent}, orchestrators={"orch": orch}) + assert orch.hooks.add_hook.called + + def test_custom_tool_labels_forwarded(self): + agent = _make_agent() + labels = {"a": "My Agent"} + make_event_queue({"a": agent}, tool_labels=labels) + # EventPublisher is constructed with our labels — check via the add_hook call arg + pub = agent.hooks.add_hook.call_args[0][0] + assert isinstance(pub, EventPublisher) + assert pub._tool_labels == labels + + def test_default_label_uses_title(self): + agent = _make_agent() + make_event_queue({"researcher": agent}) + pub = agent.hooks.add_hook.call_args[0][0] + assert "Researcher" in pub._tool_labels.get("researcher", "") + + @pytest.mark.asyncio + async def test_put_event_via_wired_callback(self): + """Verify that triggering the publisher callback enqueues an event.""" + agent = _make_agent("x") + eq = make_event_queue({"x": agent}) + pub: EventPublisher = agent.hooks.add_hook.call_args[0][0] + + event = MagicMock(spec=StreamEvent) + pub._callback(event) + + result = await eq.get() + assert result is event diff --git a/tests/unit/test_exception_usage.py b/tests/unit/test_exception_usage.py new file mode 100644 index 0000000..331e562 --- /dev/null +++ b/tests/unit/test_exception_usage.py @@ -0,0 +1,74 @@ +"""Tests verifying correct exception subclass usage in validators and planner.""" + +from __future__ import annotations + +import pytest + +from strands_compose.config.loaders.validators import validate_references +from strands_compose.config.resolvers.orchestrations.planner import topological_sort +from strands_compose.config.schema import ( + AgentDef, + AppConfig, + GraphEdgeDef, + GraphOrchestrationDef, + MCPClientDef, + SwarmOrchestrationDef, +) +from strands_compose.exceptions import CircularDependencyError, UnresolvedReferenceError + + +class TestValidatorsRaiseReferenceError: + """validate_references() should raise UnresolvedReferenceError, not generic ConfigurationError.""" + + def test_missing_model_raises_reference_error(self): + config = AppConfig( + agents={"a": AgentDef(system_prompt="test", model="nonexistent")}, + entry="a", + ) + with pytest.raises(UnresolvedReferenceError, match="nonexistent"): + validate_references(config) + + def test_missing_mcp_client_raises_reference_error(self): + config = AppConfig( + agents={"a": AgentDef(system_prompt="test", mcp=["ghost_client"])}, + entry="a", + ) + with pytest.raises(UnresolvedReferenceError, match="ghost_client"): + validate_references(config) + + def test_missing_mcp_server_in_client_raises_reference_error(self): + config = AppConfig( + agents={"a": AgentDef(system_prompt="test")}, + mcp_clients={"c": MCPClientDef(server="no_such_server")}, + entry="a", + ) + with pytest.raises(UnresolvedReferenceError, match="no_such_server"): + validate_references(config) + + +class TestPlannerRaisesCircularDependencyError: + """topological_sort() should raise CircularDependencyError for cycles.""" + + def test_mutual_dependency_raises_circular_error(self): + configs = { + "a": GraphOrchestrationDef( + entry_name="b", + edges=[GraphEdgeDef(from_agent="b", to_agent="x")], # type: ignore[call-arg] + ), + "b": GraphOrchestrationDef( + entry_name="a", + edges=[GraphEdgeDef(from_agent="a", to_agent="y")], # type: ignore[call-arg] + ), + } + with pytest.raises(CircularDependencyError, match="Circular dependency"): + topological_sort(configs) # type: ignore[arg-type] + + def test_self_referencing_orchestration_raises_circular_error(self): + configs = { + "self_loop": SwarmOrchestrationDef( + entry_name="self_loop", + agents=["self_loop"], + ), + } + with pytest.raises(CircularDependencyError, match="Circular dependency"): + topological_sort(configs) # type: ignore[arg-type] diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 0000000..820f27c --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,84 @@ +"""Tests for the exception hierarchy.""" + +from __future__ import annotations + +import pytest + +from strands_compose.exceptions import ( + CircularDependencyError, + ConfigurationError, + ImportResolutionError, + SchemaValidationError, + UnresolvedReferenceError, +) + + +class TestExceptionHierarchy: + """Verify that all custom exceptions form the correct inheritance chain.""" + + @pytest.mark.parametrize( + ("exc_cls", "parent_cls"), + [ + (ConfigurationError, ValueError), + (SchemaValidationError, ConfigurationError), + (UnresolvedReferenceError, ConfigurationError), + (CircularDependencyError, ConfigurationError), + (ImportResolutionError, ConfigurationError), + ], + ids=[ + "ConfigurationError<-ValueError", + "SchemaValidationError<-ConfigurationError", + "UnresolvedReferenceError<-ConfigurationError", + "CircularDependencyError<-ConfigurationError", + "ImportResolutionError<-ConfigurationError", + ], + ) + def test_exception_hierarchy(self, exc_cls, parent_cls): + """Each exception class is a subclass of its expected parent.""" + assert issubclass(exc_cls, parent_cls) + + def test_except_configuration_error_catches_subclasses(self): + """Existing ``except ConfigurationError`` handlers catch all subclasses.""" + for exc_cls in ( + SchemaValidationError, + UnresolvedReferenceError, + CircularDependencyError, + ImportResolutionError, + ): + with pytest.raises(ConfigurationError): + raise exc_cls("test") + + def test_except_value_error_catches_all(self): + """Existing ``except ValueError`` handlers catch all subclasses.""" + for exc_cls in ( + ConfigurationError, + SchemaValidationError, + UnresolvedReferenceError, + CircularDependencyError, + ImportResolutionError, + ): + with pytest.raises(ValueError): + raise exc_cls("test") + + def test_specific_catch_does_not_catch_siblings(self): + """An ``UnresolvedReferenceError`` should not be caught by ``except SchemaValidationError``.""" + with pytest.raises(UnresolvedReferenceError): + try: + raise UnresolvedReferenceError("bad ref") + except SchemaValidationError: + pytest.fail("Should not catch sibling exception") + + @pytest.mark.parametrize( + ("exc_cls", "msg"), + [ + (ConfigurationError, "config broken"), + (SchemaValidationError, "bad field 'x'"), + (UnresolvedReferenceError, "ref not found"), + (CircularDependencyError, "cycle detected"), + (ImportResolutionError, "import failed"), + ], + ) + def test_exception_preserves_message(self, exc_cls, msg): + """All exception classes preserve their message in str().""" + exc = exc_cls(msg) + assert str(exc) == msg diff --git a/tests/unit/test_exports.py b/tests/unit/test_exports.py new file mode 100644 index 0000000..a21c7ce --- /dev/null +++ b/tests/unit/test_exports.py @@ -0,0 +1,25 @@ +"""Export completeness tests for strands_compose top-level package.""" + +from __future__ import annotations + +import pytest + +import strands_compose + + +class TestTopLevelExports: + """Verify that the public API surface is complete.""" + + def test_all_names_are_accessible(self) -> None: + """Every name in __all__ must be reachable as an attribute.""" + for name in strands_compose.__all__: + assert hasattr(strands_compose, name), f"{name!r} in __all__ but not accessible" + + @pytest.mark.parametrize( + "name", + ["ToolNameSanitizer", "MaxToolCallsGuard", "StopGuard", "EventPublisher"], + ) + def test_key_exports_in_all(self, name: str) -> None: + """Key public classes must appear in __all__ and be importable.""" + assert name in strands_compose.__all__ + assert getattr(strands_compose, name) is not None diff --git a/tests/unit/test_golden_outputs.py b/tests/unit/test_golden_outputs.py new file mode 100644 index 0000000..3c7f447 --- /dev/null +++ b/tests/unit/test_golden_outputs.py @@ -0,0 +1,335 @@ +"""Golden output / behavioral tests for end-to-end agent invocation (R10). + +These tests use pre-recorded expected event sequences to validate that the +full EventPublisher → EventQueue pipeline produces the correct stream of +events for different agent behaviors. + +Unlike unit tests that mock individual methods, these tests exercise the +full event publishing pipeline with realistic event sequences. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +from strands_compose.hooks.event_publisher import EventPublisher +from strands_compose.types import EventType + +# --------------------------------------------------------------------------- +# Golden sequence: simple text response +# --------------------------------------------------------------------------- + + +class TestGoldenSimpleTextResponse: + """Golden test: agent receives prompt → emits tokens → completes. + + Expected event sequence: + 1. AGENT_START + 2. TOKEN("Hello") + 3. TOKEN(" world") + 4. COMPLETE(usage) + """ + + def test_simple_text_produces_correct_event_sequence(self) -> None: + """Simulate a simple agent text response and verify the event stream.""" + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="assistant") + handler = pub.as_callback_handler() + + # 1. Agent invocation starts + start_event = MagicMock() + pub._on_agent_start(start_event) + + # 2. Model streams tokens via callback_handler + handler(data="Hello") + handler(data=" world") + + # 3. Invocation completes + complete_event = MagicMock() + metrics = MagicMock() + invocation = MagicMock() + invocation.usage = {"inputTokens": 50, "outputTokens": 10, "totalTokens": 60} + metrics.latest_agent_invocation = invocation + complete_event.agent.event_loop_metrics = metrics + pub._on_complete(complete_event) + + # Verify golden sequence + assert len(events) == 4 + assert events[0].type == EventType.AGENT_START + assert events[0].agent_name == "assistant" + assert events[1].type == EventType.TOKEN + assert events[1].data["text"] == "Hello" + assert events[2].type == EventType.TOKEN + assert events[2].data["text"] == " world" + assert events[3].type == EventType.COMPLETE + assert events[3].data["usage"]["input_tokens"] == 50 + assert events[3].data["usage"]["output_tokens"] == 10 + + +# --------------------------------------------------------------------------- +# Golden sequence: tool-calling agent +# --------------------------------------------------------------------------- + + +class TestGoldenToolCallingAgent: + """Golden test: agent calls a tool mid-response. + + Expected event sequence: + 1. AGENT_START + 2. TOOL_START(search) + 3. TOOL_END(search, success) + 4. TOKEN("Based on the search...") + 5. COMPLETE(usage) + """ + + def test_tool_calling_produces_correct_event_sequence(self) -> None: + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="researcher") + handler = pub.as_callback_handler() + + # 1. Start + pub._on_agent_start(MagicMock()) + + # 2. Tool call + tool_start = MagicMock() + tool_start.tool_use = { + "name": "search", + "toolUseId": "call_abc", + "input": {"query": "latest news"}, + } + pub._on_tool_start(tool_start) + + # 3. Tool result + tool_end = MagicMock() + tool_end.tool_use = {"name": "search", "toolUseId": "call_abc"} + tool_end.result = {"content": [{"text": "Search results here"}]} + tool_end.exception = None + pub._on_tool_end(tool_end) + + # 4. Final token + handler(data="Based on the search...") + + # 5. Complete + complete = MagicMock() + metrics = MagicMock() + metrics.latest_agent_invocation = None + metrics.accumulated_usage = {"inputTokens": 100, "outputTokens": 50, "totalTokens": 150} + complete.agent.event_loop_metrics = metrics + pub._on_complete(complete) + + # Verify golden sequence + assert len(events) == 5 + assert [e.type for e in events] == [ + EventType.AGENT_START, + EventType.TOOL_START, + EventType.TOOL_END, + EventType.TOKEN, + EventType.COMPLETE, + ] + assert events[1].data["tool_name"] == "search" + assert events[1].data["tool_use_id"] == "call_abc" + assert events[2].data["status"] == "success" + assert events[2].data["tool_result"] == "Search results here" + + +# --------------------------------------------------------------------------- +# Golden sequence: reasoning then response +# --------------------------------------------------------------------------- + + +class TestGoldenReasoningThenResponse: + """Golden test: agent reasons then responds. + + Expected event sequence: + 1. AGENT_START + 2. REASONING("Let me think...") + 3. TOKEN("The answer is 42") + 4. COMPLETE + """ + + def test_reasoning_then_response_produces_correct_sequence(self) -> None: + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="thinker") + handler = pub.as_callback_handler() + + pub._on_agent_start(MagicMock()) + handler(reasoningText="Let me think...") + handler(data="The answer is 42") + + complete = MagicMock() + metrics = MagicMock() + metrics.latest_agent_invocation = None + metrics.accumulated_usage = {"inputTokens": 20, "outputTokens": 5, "totalTokens": 25} + complete.agent.event_loop_metrics = metrics + pub._on_complete(complete) + + assert len(events) == 4 + assert events[0].type == EventType.AGENT_START + assert events[1].type == EventType.REASONING + assert events[1].data["text"] == "Let me think..." + assert events[2].type == EventType.TOKEN + assert events[2].data["text"] == "The answer is 42" + assert events[3].type == EventType.COMPLETE + + +# --------------------------------------------------------------------------- +# Golden sequence: model error +# --------------------------------------------------------------------------- + + +class TestGoldenModelError: + """Golden test: model call fails → ERROR emitted, COMPLETE suppressed. + + Expected event sequence: + 1. AGENT_START + 2. ERROR(expired credentials) + (no COMPLETE) + """ + + def test_model_error_produces_correct_sequence(self) -> None: + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="broken") + + pub._on_agent_start(MagicMock()) + + # Model error + model_event = MagicMock() + model_event.exception = RuntimeError("Credentials expired") + pub._on_model_error(model_event) + + # AfterInvocationEvent fires anyway (finally block) + complete = MagicMock() + metrics = MagicMock() + metrics.latest_agent_invocation = None + metrics.accumulated_usage = {"inputTokens": 0, "outputTokens": 0, "totalTokens": 0} + complete.agent.event_loop_metrics = metrics + pub._on_complete(complete) + + # Only AGENT_START + ERROR, no COMPLETE + assert len(events) == 2 + assert events[0].type == EventType.AGENT_START + assert events[1].type == EventType.ERROR + assert "Credentials expired" in events[1].data["message"] + + +# --------------------------------------------------------------------------- +# Golden sequence: multi-agent swarm +# --------------------------------------------------------------------------- + + +class TestGoldenMultiAgentSwarm: + """Golden test: swarm orchestration lifecycle. + + Expected event sequence: + 1. MULTIAGENT_START(swarm) + 2. NODE_START(researcher) + 3. NODE_STOP(researcher) + 4. NODE_START(writer) + 5. NODE_STOP(writer) + 6. MULTIAGENT_COMPLETE(swarm) + """ + + def test_swarm_lifecycle_produces_correct_sequence(self) -> None: + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="pipeline") + + source = MagicMock() + source.__class__.__name__ = "Swarm" + + # Start swarm + ma_start = MagicMock() + ma_start.source = source + pub._on_multiagent_start(ma_start) + + # Node: researcher + n1_start = MagicMock() + n1_start.node_id = "researcher" + n1_start.source = source + pub._on_node_start(n1_start) + + n1_stop = MagicMock() + n1_stop.node_id = "researcher" + n1_stop.source = source + pub._on_node_stop(n1_stop) + + # Node: writer + n2_start = MagicMock() + n2_start.node_id = "writer" + n2_start.source = source + pub._on_node_start(n2_start) + + n2_stop = MagicMock() + n2_stop.node_id = "writer" + n2_stop.source = source + pub._on_node_stop(n2_stop) + + # Complete swarm + ma_complete = MagicMock() + ma_complete.source = source + pub._on_multiagent_complete(ma_complete) + + assert len(events) == 6 + expected_types = [ + EventType.MULTIAGENT_START, + EventType.NODE_START, + EventType.NODE_STOP, + EventType.NODE_START, + EventType.NODE_STOP, + EventType.MULTIAGENT_COMPLETE, + ] + assert [e.type for e in events] == expected_types + assert events[1].data["node_id"] == "researcher" + assert events[3].data["node_id"] == "writer" + assert all(e.agent_name == "pipeline" for e in events) + + +# --------------------------------------------------------------------------- +# Golden sequence: tool error +# --------------------------------------------------------------------------- + + +class TestGoldenToolError: + """Golden test: tool call fails → TOOL_END with error status. + + Expected event sequence: + 1. AGENT_START + 2. TOOL_START(db_query) + 3. TOOL_END(db_query, error) + 4. TOKEN("I encountered an error...") + 5. COMPLETE + """ + + def test_tool_error_produces_correct_sequence(self) -> None: + events: list = [] + pub = EventPublisher(callback=events.append, agent_name="agent") + handler = pub.as_callback_handler() + + pub._on_agent_start(MagicMock()) + + tool_start = MagicMock() + tool_start.tool_use = { + "name": "db_query", + "toolUseId": "call_db1", + "input": {"sql": "SELECT 1"}, + } + pub._on_tool_start(tool_start) + + tool_end = MagicMock() + tool_end.tool_use = {"name": "db_query", "toolUseId": "call_db1"} + tool_end.result = None + tool_end.exception = ConnectionError("DB unreachable") + pub._on_tool_end(tool_end) + + handler(data="I encountered an error...") + + complete = MagicMock() + metrics = MagicMock() + metrics.latest_agent_invocation = None + metrics.accumulated_usage = {"inputTokens": 30, "outputTokens": 15, "totalTokens": 45} + complete.agent.event_loop_metrics = metrics + pub._on_complete(complete) + + assert len(events) == 5 + assert events[2].data["status"] == "error" + assert "DB unreachable" in events[2].data["error"] + assert events[2].data["tool_result"] is None diff --git a/tests/unit/test_tools.py b/tests/unit/test_tools.py new file mode 100644 index 0000000..922db52 --- /dev/null +++ b/tests/unit/test_tools.py @@ -0,0 +1,173 @@ +"""Tests for core.tools — tool loading from files, modules, directories.""" + +from __future__ import annotations + +import os +from unittest.mock import MagicMock + +import pytest +from strands.types.tools import AgentTool + +from strands_compose.tools import ( + load_tool_function, + load_tools_from_directory, + load_tools_from_file, + resolve_tool_spec, +) + + +class TestLoadToolsFromFile: + def test_loads_tool_decorated_functions(self, tools_dir): + tools = load_tools_from_file(tools_dir / "greet.py") + assert len(tools) == 1 + assert tools[0].tool_name == "greet" + + def test_ignores_plain_functions(self, plain_tools_file): + """Plain (undecorated) public functions must NOT be collected. + + Users are required to decorate their functions with @tool. + """ + tools = load_tools_from_file(plain_tools_file) + assert tools == [] + + def test_tool_decorated_not_duplicated(self, tools_dir): + """@tool-decorated functions should not appear twice.""" + tools = load_tools_from_file(tools_dir / "greet.py") + names = [t.tool_name for t in tools] + assert names.count("greet") == 1 + + def test_file_not_found_raises(self): + with pytest.raises(FileNotFoundError): + load_tools_from_file("/nonexistent/file.py") + + +class TestLoadToolsFromDirectory: + def test_loads_all_tools_from_dir(self, tools_dir): + tools = load_tools_from_directory(tools_dir) + names = {t.tool_name for t in tools} + assert "greet" in names + assert "add_numbers" in names + + def test_skips_underscore_prefixed(self, tools_dir): + tools = load_tools_from_directory(tools_dir) + names = {t.tool_name for t in tools} + assert "HELPER_CONST" not in names + + def test_logs_debug_for_skipped_files(self, tools_dir, caplog): + import logging + + with caplog.at_level(logging.DEBUG, logger="strands_compose.tools"): + load_tools_from_directory(tools_dir) + assert any("_helpers.py" in m and "underscore-prefixed" in m for m in caplog.messages) + + def test_nonexistent_dir_raises(self): + with pytest.raises(FileNotFoundError): + load_tools_from_directory("/nonexistent/dir") + + +class TestLoadToolFunction: + def test_invalid_spec_raises(self): + with pytest.raises(ValueError, match="Invalid tool spec"): + load_tool_function("no_colon_here") + + +class TestResolveToolSpec: + def test_resolve_file_spec(self, tools_dir): + tools = resolve_tool_spec(os.path.relpath(tools_dir / "greet.py")) + assert len(tools) == 1 + + def test_resolve_directory_spec(self, tools_dir): + tools = resolve_tool_spec(os.path.relpath(tools_dir) + os.sep) + assert len(tools) >= 2 + + def test_resolve_file_with_function(self, tools_dir): + file_path = os.path.relpath(tools_dir / "greet.py") + tools = resolve_tool_spec(f"{file_path}:greet") + assert len(tools) == 1 + + def test_resolve_file_colon_plain_function_autowraps(self, plain_tools_file, caplog): + """Plain function named explicitly via file colon spec is auto-wrapped. + + The function must become an AgentTool and a warning must be logged + so the user knows to add @tool explicitly. + """ + import logging + + file_path = os.path.relpath(plain_tools_file) + with caplog.at_level(logging.WARNING, logger="strands_compose.tools"): + tools = resolve_tool_spec(f"{file_path}:count_words") + + assert len(tools) == 1 + assert isinstance(tools[0], AgentTool) + assert tools[0].tool_name == "count_words" + assert any("count_words" in m for m in caplog.messages) + assert any("@tool" in m for m in caplog.messages) + + +# --------------------------------------------------------------------------- +# Module-based tool spec resolution (R4 — coverage gap) +# --------------------------------------------------------------------------- + + +class TestResolveToolSpecModuleBased: + """Test resolve_tool_spec for module-based specs (no filesystem path markers).""" + + def test_module_colon_function_loads_tool(self) -> None: + """'module:function' spec loads a single tool via load_tool_function.""" + from unittest.mock import patch + + mock_tool = MagicMock(spec=AgentTool) + + with patch( + "strands_compose.tools.loaders.load_tool_function", return_value=mock_tool + ) as mock_load: + tools = resolve_tool_spec("my_package.tools:my_func") + + mock_load.assert_called_once_with("my_package.tools:my_func") + assert tools == [mock_tool] + + def test_module_path_loads_all_tools(self) -> None: + """'module.path' spec (no colon) loads all tools from the module.""" + from unittest.mock import patch + + mock_tools = [MagicMock(spec=AgentTool), MagicMock(spec=AgentTool)] + + with patch( + "strands_compose.tools.loaders.load_tools_from_module", return_value=mock_tools + ) as mock_load: + tools = resolve_tool_spec("my_package.tools") + + mock_load.assert_called_once_with("my_package.tools") + assert tools == mock_tools + + def test_resolve_tool_specs_multiple(self) -> None: + """resolve_tool_specs flattens results from multiple specs.""" + from unittest.mock import patch + + from strands_compose.tools import resolve_tool_specs + + tool1 = MagicMock(spec=AgentTool) + tool2 = MagicMock(spec=AgentTool) + + with patch( + "strands_compose.tools.loaders.resolve_tool_spec", + side_effect=[[tool1], [tool2]], + ): + tools = resolve_tool_specs(["spec1", "spec2"]) + + assert len(tools) == 2 + + def test_file_colon_nonexistent_attr_raises(self, tools_dir) -> None: + """File colon spec with nonexistent function raises AttributeError.""" + file_path = os.path.relpath(tools_dir / "greet.py") + with pytest.raises(AttributeError, match="has no attribute"): + resolve_tool_spec(f"{file_path}:nonexistent_func") + + def test_not_a_directory_raises(self, tmp_path) -> None: + """resolve_tool_spec raises NotADirectoryError for a file posing as dir.""" + f = tmp_path / "not_a_dir.py" + f.write_text("x = 1") + with pytest.raises(NotADirectoryError): + from strands_compose.tools import load_tools_from_directory + + load_tools_from_directory(str(f)) diff --git a/tests/unit/test_tools_module.py b/tests/unit/test_tools_module.py new file mode 100644 index 0000000..bb1b090 --- /dev/null +++ b/tests/unit/test_tools_module.py @@ -0,0 +1,92 @@ +"""Tests for tools.load_tools_from_module.""" + +from __future__ import annotations + +import sys +import types + +import pytest +from strands.tools.decorator import tool +from strands.types.tools import AgentTool + +from strands_compose.tools import load_tools_from_module + + +class TestLoadToolsFromModule: + """Unit tests for load_tools_from_module().""" + + def test_loads_tool_decorated_functions(self, tmp_path): + """Creates a temporary module with @tool functions and loads them.""" + mod = types.ModuleType("_test_module_with_tools") + + @tool + def greet(name: str) -> str: + """Say hello.""" + return f"Hello, {name}!" + + setattr(mod, "greet", greet) + sys.modules["_test_module_with_tools"] = mod + try: + tools = load_tools_from_module("_test_module_with_tools") + assert len(tools) == 1 + assert isinstance(tools[0], AgentTool) + assert tools[0].tool_name == "greet" + finally: + sys.modules.pop("_test_module_with_tools", None) + + def test_ignores_plain_functions(self): + """Plain (undecorated) public functions must NOT be collected.""" + mod = types.ModuleType("_test_module_plain") + setattr(mod, "plain_func", lambda: None) + sys.modules["_test_module_plain"] = mod + try: + tools = load_tools_from_module("_test_module_plain") + assert tools == [] + finally: + sys.modules.pop("_test_module_plain", None) + + def test_ignores_private_attributes(self): + """Underscore-prefixed attributes should be skipped.""" + mod = types.ModuleType("_test_module_private") + + @tool + def _private_tool(x: int) -> int: + """Private tool.""" + return x + + setattr(mod, "_private_tool", _private_tool) + sys.modules["_test_module_private"] = mod + try: + tools = load_tools_from_module("_test_module_private") + assert tools == [] + finally: + sys.modules.pop("_test_module_private", None) + + def test_nonexistent_module_raises(self): + with pytest.raises(ImportError): + load_tools_from_module("nonexistent.module.path") + + def test_multiple_tools_collected(self): + """Module with multiple @tool functions returns all of them.""" + mod = types.ModuleType("_test_module_multi") + + @tool + def tool_a(x: int) -> int: + """Tool A.""" + return x + + @tool + def tool_b(y: str) -> str: + """Tool B.""" + return y + + setattr(mod, "tool_a", tool_a) + setattr(mod, "tool_b", tool_b) + sys.modules["_test_module_multi"] = mod + try: + tools = load_tools_from_module("_test_module_multi") + names = {t.tool_name for t in tools} + assert "tool_a" in names + assert "tool_b" in names + finally: + sys.modules.pop("_test_module_multi", None) diff --git a/tests/unit/test_types.py b/tests/unit/test_types.py new file mode 100644 index 0000000..34a89a8 --- /dev/null +++ b/tests/unit/test_types.py @@ -0,0 +1,58 @@ +"""Tests for the EventType StrEnum.""" + +from __future__ import annotations + +from enum import StrEnum + +import pytest + +from strands_compose.types import EventType + + +class TestEventTypeEnum: + """Verify EventType is a proper StrEnum with all expected members.""" + + def test_is_str_enum_with_string_values(self): + """EventType is a StrEnum and all members are strings.""" + assert issubclass(EventType, StrEnum) + for member in EventType: + assert isinstance(member, str) + assert isinstance(member.value, str) + + @pytest.mark.parametrize( + ("member", "expected_value"), + [ + ("TOKEN", "token"), + ("AGENT_START", "agent_start"), + ("COMPLETE", "complete"), + ("ERROR", "error"), + ("TOOL_START", "tool_start"), + ("TOOL_END", "tool_end"), + ("REASONING", "reasoning"), + ("NODE_START", "node_start"), + ("NODE_STOP", "node_stop"), + ("HANDOFF", "handoff"), + ("MULTIAGENT_START", "multiagent_start"), + ("MULTIAGENT_COMPLETE", "multiagent_complete"), + ], + ) + def test_string_comparison_works(self, member, expected_value): + """StrEnum values compare equal to their plain string counterparts.""" + assert EventType[member] == expected_value + + def test_all_members_present(self): + expected = { + "AGENT_START", + "TOKEN", + "TOOL_START", + "TOOL_END", + "REASONING", + "COMPLETE", + "ERROR", + "NODE_START", + "NODE_STOP", + "HANDOFF", + "MULTIAGENT_START", + "MULTIAGENT_COMPLETE", + } + assert set(EventType.__members__) == expected diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py new file mode 100644 index 0000000..8341599 --- /dev/null +++ b/tests/unit/test_utils.py @@ -0,0 +1,169 @@ +"""Tests for core.utils — import_from_path, load_module_from_file, cli_errors.""" + +from __future__ import annotations + +import logging + +import pytest + +from strands_compose.utils import ( + _format_exception, + cli_errors, + import_from_path, + load_module_from_file, +) + + +class TestImportFromPath: + def test_valid_import(self): + result = import_from_path("os.path:join") + assert result is __import__("os").path.join + + def test_missing_colon_raises_value_error(self): + with pytest.raises(ValueError, match=r"must be in 'module\.path:ObjectName' format"): + import_from_path("os.path.join") + + def test_nonexistent_module_raises_import_error(self): + with pytest.raises(ImportError): + import_from_path("nonexistent.module:Thing") + + def test_nonexistent_attr_raises_attribute_error(self): + with pytest.raises(AttributeError): + import_from_path("os.path:nonexistent_function") + + +class TestLoadModuleFromFile: + def test_loads_module_from_file(self, tmp_path): + py = tmp_path / "sample.py" + py.write_text("VALUE = 42\n") + mod = load_module_from_file(py) + assert mod.VALUE == 42 + + def test_file_not_found_raises(self): + with pytest.raises(FileNotFoundError): + load_module_from_file("/nonexistent/path.py") + + def test_syntax_error_raises_import_error(self, tmp_path): + py = tmp_path / "bad.py" + py.write_text("def broken(\n") + with pytest.raises(ImportError, match="Failed to load"): + load_module_from_file(py) + + def test_deterministic_module_name(self, tmp_path): + py = tmp_path / "mod.py" + py.write_text("X = 1\n") + m1 = load_module_from_file(py) + m2 = load_module_from_file(py) + assert m1.__name__ == m2.__name__ + + +# ── cli_errors ──────────────────────────────────────────────────────────────── + + +class TestFormatException: + """Unit tests for the _format_exception helper.""" + + def test_builtin_exception_no_module_prefix(self): + exc = ValueError("bad value") + assert _format_exception(exc) == "ValueError:\nbad value" + + def test_custom_exception_includes_module(self): + from strands_compose.exceptions import ConfigurationError + + exc = ConfigurationError("invalid config") + assert ( + _format_exception(exc) + == "strands_compose.exceptions.ConfigurationError:\ninvalid config" + ) + + def test_exception_without_message(self): + exc = RuntimeError() + assert _format_exception(exc) == "RuntimeError" + + def test_multiline_message_preserved(self): + msg = "line one\nline two\nline three" + result = _format_exception(ValueError(msg)) + assert result == f"ValueError:\n{msg}" + + +class TestCliErrors: + """Tests for the cli_errors context manager.""" + + def test_no_exception_passes_through(self): + with cli_errors(exit_code=0): + x = 1 + 1 + assert x == 2 + + def test_catches_exception_and_prints(self, capsys): + with cli_errors(exit_code=0): + raise ValueError("something went wrong") + captured = capsys.readouterr() + assert "ValueError:" in captured.err + assert "something went wrong" in captured.err + + def test_keyboard_interrupt_not_caught(self): + with pytest.raises(KeyboardInterrupt): + with cli_errors(exit_code=0): + raise KeyboardInterrupt + + def test_system_exit_not_caught(self): + with pytest.raises(SystemExit): + with cli_errors(exit_code=0): + raise SystemExit(0) + + def test_calls_sys_exit_with_code(self): + with pytest.raises(SystemExit) as exc_info: + with cli_errors(exit_code=1): + raise RuntimeError("boom") + assert exc_info.value.code == 1 + + def test_exit_code_zero_suppresses_sys_exit(self, capsys): + # exit_code=0 means suppress sys.exit — useful for tests + with cli_errors(exit_code=0): + raise RuntimeError("boom") + captured = capsys.readouterr() + assert "RuntimeError:" in captured.err + assert "boom" in captured.err + + def test_custom_exception_formatted(self, capsys): + from strands_compose.exceptions import ConfigurationError + + with cli_errors(exit_code=0): + raise ConfigurationError("bad yaml") + captured = capsys.readouterr() + assert "strands_compose.exceptions.ConfigurationError:" in captured.err + assert "bad yaml" in captured.err + + +class TestSuppressTaskExceptions: + """Tests for _SuppressTaskExceptions log filter.""" + + def test_suppresses_task_exception_message(self): + from strands_compose.utils import _SuppressTaskExceptions + + filt = _SuppressTaskExceptions() + record = logging.LogRecord( + name="asyncio", + level=logging.ERROR, + pathname="", + lineno=0, + msg="Task exception was never retrieved", + args=(), + exc_info=None, + ) + assert filt.filter(record) is False + + def test_passes_unrelated_message(self): + from strands_compose.utils import _SuppressTaskExceptions + + filt = _SuppressTaskExceptions() + record = logging.LogRecord( + name="asyncio", + level=logging.INFO, + pathname="", + lineno=0, + msg="Some normal message", + args=(), + exc_info=None, + ) + assert filt.filter(record) is True diff --git a/tests/unit/test_wire.py b/tests/unit/test_wire.py new file mode 100644 index 0000000..678eb2e --- /dev/null +++ b/tests/unit/test_wire.py @@ -0,0 +1,51 @@ +"""Tests for core.wire — StreamEvent.""" + +from __future__ import annotations + +from strands_compose.types import EventType +from strands_compose.wire import StreamEvent + + +class TestStreamEvent: + def test_asdict_serializes_timestamp(self): + event = StreamEvent(type=EventType.TOKEN, agent_name="a") + d = event.asdict() + assert d["type"] == EventType.TOKEN + assert d["agent_name"] == "a" + assert isinstance(d["timestamp"], str) # ISO-formatted + + def test_asdict_includes_data(self): + event = StreamEvent(type=EventType.TOKEN, agent_name="a", data={"text": "hi"}) + assert event.asdict()["data"] == {"text": "hi"} + + def test_from_dict_round_trips_timestamp(self): + original = StreamEvent(type=EventType.TOKEN, agent_name="a") + restored = StreamEvent.from_dict(original.asdict()) + assert restored.timestamp == original.timestamp + assert restored.type == original.type + assert restored.agent_name == original.agent_name + + +class TestStreamEventEquality: + def test_eq_ignores_timestamp(self): + from datetime import datetime, timedelta, timezone + + t1 = datetime.now(tz=timezone.utc) + t2 = t1 + timedelta(seconds=5) + e1 = StreamEvent(type=EventType.TOKEN, agent_name="a", timestamp=t1, data={"text": "hi"}) + e2 = StreamEvent(type=EventType.TOKEN, agent_name="a", timestamp=t2, data={"text": "hi"}) + assert e1 == e2 + + def test_eq_different_type_not_equal(self): + e1 = StreamEvent(type=EventType.TOKEN, agent_name="a") + e2 = StreamEvent(type=EventType.COMPLETE, agent_name="a") + assert e1 != e2 + + def test_eq_different_data_not_equal(self): + e1 = StreamEvent(type=EventType.TOKEN, agent_name="a", data={"text": "x"}) + e2 = StreamEvent(type=EventType.TOKEN, agent_name="a", data={"text": "y"}) + assert e1 != e2 + + def test_eq_not_stream_event(self): + e = StreamEvent(type=EventType.TOKEN, agent_name="a") + assert e != "not an event" diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..8e4ee47 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2179 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +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 = "argcomplete" +version = "3.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[package]] +name = "bedrock-agentcore" +version = "1.4.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "uvicorn" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/5c/2ad1747ff2bc4b6bb8828fdc8e769f6c34daa0c4ca4d853cff603ea04aeb/bedrock_agentcore-1.4.7.tar.gz", hash = "sha256:422805482e47593010128a86495dff644507624b00c6e09950613c7241ae5375", size = 483923, upload-time = "2026-03-18T22:46:37.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/e7/df6cbe814292353460c0988504eee4e90cc08dde03bf5e1da85176b5f0b4/bedrock_agentcore-1.4.7-py3-none-any.whl", hash = "sha256:7515ddf779a4f32fd4a5c8dcf29c9399babe0ea14ea9004d2c69bcad40754622", size = 148250, upload-time = "2026-03-18T22:46:36.662Z" }, +] + +[[package]] +name = "boto3" +version = "1.42.73" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, + { name = "jmespath" }, + { name = "s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/8b/d00575be514744ca4839e7d85bf4a8a3c7b6b4574433291e58d14c68ae09/boto3-1.42.73.tar.gz", hash = "sha256:d37b58d6cd452ca808dd6823ae19ca65b6244096c5125ef9052988b337298bae", size = 112775, upload-time = "2026-03-20T19:39:52.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/05/1fcf03d90abaa3d0b42a6bfd10231dd709493ecbacf794aa2eea5eae6841/boto3-1.42.73-py3-none-any.whl", hash = "sha256:1f81b79b873f130eeab14bb556417a7c66d38f3396b7f2fe3b958b3f9094f455", size = 140556, upload-time = "2026-03-20T19:39:50.298Z" }, +] + +[[package]] +name = "botocore" +version = "1.42.73" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jmespath" }, + { name = "python-dateutil" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/23/0c88ca116ef63b1ae77c901cd5d2095d22a8dbde9e80df74545db4a061b4/botocore-1.42.73.tar.gz", hash = "sha256:575858641e4949aaf2af1ced145b8524529edf006d075877af6b82ff96ad854c", size = 15008008, upload-time = "2026-03-20T19:39:40.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/65/971f3d55015f4d133a6ff3ad74cd39f4b8dd8f53f7775a3c2ad378ea5145/botocore-1.42.73-py3-none-any.whl", hash = "sha256:7b62e2a12f7a1b08eb7360eecd23bb16fe3b7ab7f5617cf91b25476c6f86a0fe", size = 14681861, upload-time = "2026-03-20T19:39:35.341Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +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/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { 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.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/60/e3bec1881450851b087e301bedc3daa9377a4d45f1c26aa90b0b235e38aa/charset_normalizer-3.4.6.tar.gz", hash = "sha256:1ae6b62897110aa7c79ea2f5dd38d1abca6db663687c0b1ad9aed6f6bae3d9d6", size = 143363, upload-time = "2026-03-15T18:53:25.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/28/ff6f234e628a2de61c458be2779cb182bc03f6eec12200d4a525bbfc9741/charset_normalizer-3.4.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:82060f995ab5003a2d6e0f4ad29065b7672b6593c8c63559beefe5b443242c3e", size = 293582, upload-time = "2026-03-15T18:50:25.454Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b7/b1a117e5385cbdb3205f6055403c2a2a220c5ea80b8716c324eaf75c5c95/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60c74963d8350241a79cb8feea80e54d518f72c26db618862a8f53e5023deaf9", size = 197240, upload-time = "2026-03-15T18:50:27.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5f/2574f0f09f3c3bc1b2f992e20bce6546cb1f17e111c5be07308dc5427956/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e4333fb15c83f7d1482a76d45a0818897b3d33f00efd215528ff7c51b8e35d", size = 217363, upload-time = "2026-03-15T18:50:28.601Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d1/0ae20ad77bc949ddd39b51bf383b6ca932f2916074c95cad34ae465ab71f/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bc72863f4d9aba2e8fd9085e63548a324ba706d2ea2c83b260da08a59b9482de", size = 212994, upload-time = "2026-03-15T18:50:30.102Z" }, + { url = "https://files.pythonhosted.org/packages/60/ac/3233d262a310c1b12633536a07cde5ddd16985e6e7e238e9f3f9423d8eb9/charset_normalizer-3.4.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9cc4fc6c196d6a8b76629a70ddfcd4635a6898756e2d9cac5565cf0654605d73", size = 204697, upload-time = "2026-03-15T18:50:31.654Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/8a18fc411f085b82303cfb7154eed5bd49c77035eb7608d049468b53f87c/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:0c173ce3a681f309f31b87125fecec7a5d1347261ea11ebbb856fa6006b23c8c", size = 191673, upload-time = "2026-03-15T18:50:33.433Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a7/11cfe61d6c5c5c7438d6ba40919d0306ed83c9ab957f3d4da2277ff67836/charset_normalizer-3.4.6-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c907cdc8109f6c619e6254212e794d6548373cc40e1ec75e6e3823d9135d29cc", size = 201120, upload-time = "2026-03-15T18:50:35.105Z" }, + { url = "https://files.pythonhosted.org/packages/b5/10/cf491fa1abd47c02f69687046b896c950b92b6cd7337a27e6548adbec8e4/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:404a1e552cf5b675a87f0651f8b79f5f1e6fd100ee88dc612f89aa16abd4486f", size = 200911, upload-time = "2026-03-15T18:50:36.819Z" }, + { url = "https://files.pythonhosted.org/packages/28/70/039796160b48b18ed466fde0af84c1b090c4e288fae26cd674ad04a2d703/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e3c701e954abf6fc03a49f7c579cc80c2c6cc52525340ca3186c41d3f33482ef", size = 192516, upload-time = "2026-03-15T18:50:38.228Z" }, + { url = "https://files.pythonhosted.org/packages/ff/34/c56f3223393d6ff3124b9e78f7de738047c2d6bc40a4f16ac0c9d7a1cb3c/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:7a6967aaf043bceabab5412ed6bd6bd26603dae84d5cb75bf8d9a74a4959d398", size = 218795, upload-time = "2026-03-15T18:50:39.664Z" }, + { url = "https://files.pythonhosted.org/packages/e8/3b/ce2d4f86c5282191a041fdc5a4ce18f1c6bd40a5bd1f74cf8625f08d51c1/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5feb91325bbceade6afab43eb3b508c63ee53579fe896c77137ded51c6b6958e", size = 201833, upload-time = "2026-03-15T18:50:41.552Z" }, + { url = "https://files.pythonhosted.org/packages/3b/9b/b6a9f76b0fd7c5b5ec58b228ff7e85095370282150f0bd50b3126f5506d6/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:f820f24b09e3e779fe84c3c456cb4108a7aa639b0d1f02c28046e11bfcd088ed", size = 213920, upload-time = "2026-03-15T18:50:43.33Z" }, + { url = "https://files.pythonhosted.org/packages/ae/98/7bc23513a33d8172365ed30ee3a3b3fe1ece14a395e5fc94129541fc6003/charset_normalizer-3.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b35b200d6a71b9839a46b9b7fff66b6638bb52fc9658aa58796b0326595d3021", size = 206951, upload-time = "2026-03-15T18:50:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/32/73/c0b86f3d1458468e11aec870e6b3feac931facbe105a894b552b0e518e79/charset_normalizer-3.4.6-cp311-cp311-win32.whl", hash = "sha256:9ca4c0b502ab399ef89248a2c84c54954f77a070f28e546a85e91da627d1301e", size = 143703, upload-time = "2026-03-15T18:50:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e3/76f2facfe8eddee0bbd38d2594e709033338eae44ebf1738bcefe0a06185/charset_normalizer-3.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:a9e68c9d88823b274cf1e72f28cb5dc89c990edf430b0bfd3e2fb0785bfeabf4", size = 153857, upload-time = "2026-03-15T18:50:47.563Z" }, + { url = "https://files.pythonhosted.org/packages/e2/dc/9abe19c9b27e6cd3636036b9d1b387b78c40dedbf0b47f9366737684b4b0/charset_normalizer-3.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:97d0235baafca5f2b09cf332cc275f021e694e8362c6bb9c96fc9a0eb74fc316", size = 142751, upload-time = "2026-03-15T18:50:49.234Z" }, + { url = "https://files.pythonhosted.org/packages/e5/62/c0815c992c9545347aeea7859b50dc9044d147e2e7278329c6e02ac9a616/charset_normalizer-3.4.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2ef7fedc7a6ecbe99969cd09632516738a97eeb8bd7258bf8a0f23114c057dab", size = 295154, upload-time = "2026-03-15T18:50:50.88Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/bdca6613c2e3c58c7421891d80cc3efa1d32e882f7c4a7ee6039c3fc951a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4ea868bc28109052790eb2b52a9ab33f3aa7adc02f96673526ff47419490e21", size = 199191, upload-time = "2026-03-15T18:50:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/6c/92/9934d1bbd69f7f398b38c5dae1cbf9cc672e7c34a4adf7b17c0a9c17d15d/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:836ab36280f21fc1a03c99cd05c6b7af70d2697e374c7af0b61ed271401a72a2", size = 218674, upload-time = "2026-03-15T18:50:54.102Z" }, + { url = "https://files.pythonhosted.org/packages/af/90/25f6ab406659286be929fd89ab0e78e38aa183fc374e03aa3c12d730af8a/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f1ce721c8a7dfec21fcbdfe04e8f68174183cf4e8188e0645e92aa23985c57ff", size = 215259, upload-time = "2026-03-15T18:50:55.616Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ef/79a463eb0fff7f96afa04c1d4c51f8fc85426f918db467854bfb6a569ce3/charset_normalizer-3.4.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e28d62a8fc7a1fa411c43bd65e346f3bce9716dc51b897fbe930c5987b402d5", size = 207276, upload-time = "2026-03-15T18:50:57.054Z" }, + { url = "https://files.pythonhosted.org/packages/f7/72/d0426afec4b71dc159fa6b4e68f868cd5a3ecd918fec5813a15d292a7d10/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:530d548084c4a9f7a16ed4a294d459b4f229db50df689bfe92027452452943a0", size = 195161, upload-time = "2026-03-15T18:50:58.686Z" }, + { url = "https://files.pythonhosted.org/packages/bf/18/c82b06a68bfcb6ce55e508225d210c7e6a4ea122bfc0748892f3dc4e8e11/charset_normalizer-3.4.6-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30f445ae60aad5e1f8bdbb3108e39f6fbc09f4ea16c815c66578878325f8f15a", size = 203452, upload-time = "2026-03-15T18:51:00.196Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/0c25979b92f8adafdbb946160348d8d44aa60ce99afdc27df524379875cb/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ac2393c73378fea4e52aa56285a3d64be50f1a12395afef9cce47772f60334c2", size = 202272, upload-time = "2026-03-15T18:51:01.703Z" }, + { url = "https://files.pythonhosted.org/packages/2e/3d/7fea3e8fe84136bebbac715dd1221cc25c173c57a699c030ab9b8900cbb7/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:90ca27cd8da8118b18a52d5f547859cc1f8354a00cd1e8e5120df3e30d6279e5", size = 195622, upload-time = "2026-03-15T18:51:03.526Z" }, + { url = "https://files.pythonhosted.org/packages/57/8a/d6f7fd5cb96c58ef2f681424fbca01264461336d2a7fc875e4446b1f1346/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e5a94886bedca0f9b78fecd6afb6629142fd2605aa70a125d49f4edc6037ee6", size = 220056, upload-time = "2026-03-15T18:51:05.269Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/478cdda782c8c9c3fb5da3cc72dd7f331f031e7f1363a893cdd6ca0f8de0/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:695f5c2823691a25f17bc5d5ffe79fa90972cc34b002ac6c843bb8a1720e950d", size = 203751, upload-time = "2026-03-15T18:51:06.858Z" }, + { url = "https://files.pythonhosted.org/packages/75/fc/cc2fcac943939c8e4d8791abfa139f685e5150cae9f94b60f12520feaa9b/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:231d4da14bcd9301310faf492051bee27df11f2bc7549bc0bb41fef11b82daa2", size = 216563, upload-time = "2026-03-15T18:51:08.564Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b7/a4add1d9a5f68f3d037261aecca83abdb0ab15960a3591d340e829b37298/charset_normalizer-3.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a056d1ad2633548ca18ffa2f85c202cfb48b68615129143915b8dc72a806a923", size = 209265, upload-time = "2026-03-15T18:51:10.312Z" }, + { url = "https://files.pythonhosted.org/packages/6c/18/c094561b5d64a24277707698e54b7f67bd17a4f857bbfbb1072bba07c8bf/charset_normalizer-3.4.6-cp312-cp312-win32.whl", hash = "sha256:c2274ca724536f173122f36c98ce188fd24ce3dad886ec2b7af859518ce008a4", size = 144229, upload-time = "2026-03-15T18:51:11.694Z" }, + { url = "https://files.pythonhosted.org/packages/ab/20/0567efb3a8fd481b8f34f739ebddc098ed062a59fed41a8d193a61939e8f/charset_normalizer-3.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:c8ae56368f8cc97c7e40a7ee18e1cedaf8e780cd8bc5ed5ac8b81f238614facb", size = 154277, upload-time = "2026-03-15T18:51:13.004Z" }, + { url = "https://files.pythonhosted.org/packages/15/57/28d79b44b51933119e21f65479d0864a8d5893e494cf5daab15df0247c17/charset_normalizer-3.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:899d28f422116b08be5118ef350c292b36fc15ec2daeb9ea987c89281c7bb5c4", size = 142817, upload-time = "2026-03-15T18:51:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1d/4fdabeef4e231153b6ed7567602f3b68265ec4e5b76d6024cf647d43d981/charset_normalizer-3.4.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:11afb56037cbc4b1555a34dd69151e8e069bee82e613a73bef6e714ce733585f", size = 294823, upload-time = "2026-03-15T18:51:15.755Z" }, + { url = "https://files.pythonhosted.org/packages/47/7b/20e809b89c69d37be748d98e84dce6820bf663cf19cf6b942c951a3e8f41/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423fb7e748a08f854a08a222b983f4df1912b1daedce51a72bd24fe8f26a1843", size = 198527, upload-time = "2026-03-15T18:51:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/4f8d27527d59c039dce6f7622593cdcd3d70a8504d87d09eb11e9fdc6062/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d73beaac5e90173ac3deb9928a74763a6d230f494e4bfb422c217a0ad8e629bf", size = 218388, upload-time = "2026-03-15T18:51:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/f6/9b/4770ccb3e491a9bacf1c46cc8b812214fe367c86a96353ccc6daf87b01ec/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d60377dce4511655582e300dc1e5a5f24ba0cb229005a1d5c8d0cb72bb758ab8", size = 214563, upload-time = "2026-03-15T18:51:20.374Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/a199d245894b12db0b957d627516c78e055adc3a0d978bc7f65ddaf7c399/charset_normalizer-3.4.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:530e8cebeea0d76bdcf93357aa5e41336f48c3dc709ac52da2bb167c5b8271d9", size = 206587, upload-time = "2026-03-15T18:51:21.807Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/3def227f1ec56f5c69dfc8392b8bd63b11a18ca8178d9211d7cc5e5e4f27/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:a26611d9987b230566f24a0a125f17fe0de6a6aff9f25c9f564aaa2721a5fb88", size = 194724, upload-time = "2026-03-15T18:51:23.508Z" }, + { url = "https://files.pythonhosted.org/packages/58/ab/9318352e220c05efd31c2779a23b50969dc94b985a2efa643ed9077bfca5/charset_normalizer-3.4.6-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34315ff4fc374b285ad7f4a0bf7dcbfe769e1b104230d40f49f700d4ab6bbd84", size = 202956, upload-time = "2026-03-15T18:51:25.239Z" }, + { url = "https://files.pythonhosted.org/packages/75/13/f3550a3ac25b70f87ac98c40d3199a8503676c2f1620efbf8d42095cfc40/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ddd609f9e1af8c7bd6e2aca279c931aefecd148a14402d4e368f3171769fd", size = 201923, upload-time = "2026-03-15T18:51:26.682Z" }, + { url = "https://files.pythonhosted.org/packages/1b/db/c5c643b912740b45e8eec21de1bbab8e7fc085944d37e1e709d3dcd9d72f/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:80d0a5615143c0b3225e5e3ef22c8d5d51f3f72ce0ea6fb84c943546c7b25b6c", size = 195366, upload-time = "2026-03-15T18:51:28.129Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/3b1c62744f9b2448443e0eb160d8b001c849ec3fef591e012eda6484787c/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:92734d4d8d187a354a556626c221cd1a892a4e0802ccb2af432a1d85ec012194", size = 219752, upload-time = "2026-03-15T18:51:29.556Z" }, + { url = "https://files.pythonhosted.org/packages/f6/98/32ffbaf7f0366ffb0445930b87d103f6b406bc2c271563644bde8a2b1093/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:613f19aa6e082cf96e17e3ffd89383343d0d589abda756b7764cf78361fd41dc", size = 203296, upload-time = "2026-03-15T18:51:30.921Z" }, + { url = "https://files.pythonhosted.org/packages/41/12/5d308c1bbe60cabb0c5ef511574a647067e2a1f631bc8634fcafaccd8293/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2b1a63e8224e401cafe7739f77efd3f9e7f5f2026bda4aead8e59afab537784f", size = 215956, upload-time = "2026-03-15T18:51:32.399Z" }, + { url = "https://files.pythonhosted.org/packages/53/e9/5f85f6c5e20669dbe56b165c67b0260547dea97dba7e187938833d791687/charset_normalizer-3.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cceb5473417d28edd20c6c984ab6fee6c6267d38d906823ebfe20b03d607dc2", size = 208652, upload-time = "2026-03-15T18:51:34.214Z" }, + { url = "https://files.pythonhosted.org/packages/f1/11/897052ea6af56df3eef3ca94edafee410ca699ca0c7b87960ad19932c55e/charset_normalizer-3.4.6-cp313-cp313-win32.whl", hash = "sha256:d7de2637729c67d67cf87614b566626057e95c303bc0a55ffe391f5205e7003d", size = 143940, upload-time = "2026-03-15T18:51:36.15Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5c/724b6b363603e419829f561c854b87ed7c7e31231a7908708ac086cdf3e2/charset_normalizer-3.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:572d7c822caf521f0525ba1bce1a622a0b85cf47ffbdae6c9c19e3b5ac3c4389", size = 154101, upload-time = "2026-03-15T18:51:37.876Z" }, + { url = "https://files.pythonhosted.org/packages/01/a5/7abf15b4c0968e47020f9ca0935fb3274deb87cb288cd187cad92e8cdffd/charset_normalizer-3.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a4474d924a47185a06411e0064b803c68be044be2d60e50e8bddcc2649957c1f", size = 143109, upload-time = "2026-03-15T18:51:39.565Z" }, + { url = "https://files.pythonhosted.org/packages/25/6f/ffe1e1259f384594063ea1869bfb6be5cdb8bc81020fc36c3636bc8302a1/charset_normalizer-3.4.6-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9cc6e6d9e571d2f863fa77700701dae73ed5f78881efc8b3f9a4398772ff53e8", size = 294458, upload-time = "2026-03-15T18:51:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/56/60/09bb6c13a8c1016c2ed5c6a6488e4ffef506461aa5161662bd7636936fb1/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef5960d965e67165d75b7c7ffc60a83ec5abfc5c11b764ec13ea54fbef8b4421", size = 199277, upload-time = "2026-03-15T18:51:42.953Z" }, + { url = "https://files.pythonhosted.org/packages/00/50/dcfbb72a5138bbefdc3332e8d81a23494bf67998b4b100703fd15fa52d81/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b3694e3f87f8ac7ce279d4355645b3c878d24d1424581b46282f24b92f5a4ae2", size = 218758, upload-time = "2026-03-15T18:51:44.339Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/d79a9a191bb75f5aa81f3aaaa387ef29ce7cb7a9e5074ba8ea095cc073c2/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5d11595abf8dd942a77883a39d81433739b287b6aa71620f15164f8096221b30", size = 215299, upload-time = "2026-03-15T18:51:45.871Z" }, + { url = "https://files.pythonhosted.org/packages/76/7e/bc8911719f7084f72fd545f647601ea3532363927f807d296a8c88a62c0d/charset_normalizer-3.4.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7bda6eebafd42133efdca535b04ccb338ab29467b3f7bf79569883676fc628db", size = 206811, upload-time = "2026-03-15T18:51:47.308Z" }, + { url = "https://files.pythonhosted.org/packages/e2/40/c430b969d41dda0c465aa36cc7c2c068afb67177bef50905ac371b28ccc7/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:bbc8c8650c6e51041ad1be191742b8b421d05bbd3410f43fa2a00c8db87678e8", size = 193706, upload-time = "2026-03-15T18:51:48.849Z" }, + { url = "https://files.pythonhosted.org/packages/48/15/e35e0590af254f7df984de1323640ef375df5761f615b6225ba8deb9799a/charset_normalizer-3.4.6-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:22c6f0c2fbc31e76c3b8a86fba1a56eda6166e238c29cdd3d14befdb4a4e4815", size = 202706, upload-time = "2026-03-15T18:51:50.257Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bd/f736f7b9cc5e93a18b794a50346bb16fbfd6b37f99e8f306f7951d27c17c/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7edbed096e4a4798710ed6bc75dcaa2a21b68b6c356553ac4823c3658d53743a", size = 202497, upload-time = "2026-03-15T18:51:52.012Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/2cc9e3e7dfdf7760a6ed8da7446d22536f3d0ce114ac63dee2a5a3599e62/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:7f9019c9cb613f084481bd6a100b12e1547cf2efe362d873c2e31e4035a6fa43", size = 193511, upload-time = "2026-03-15T18:51:53.723Z" }, + { url = "https://files.pythonhosted.org/packages/9e/cb/5be49b5f776e5613be07298c80e1b02a2d900f7a7de807230595c85a8b2e/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:58c948d0d086229efc484fe2f30c2d382c86720f55cd9bc33591774348ad44e0", size = 220133, upload-time = "2026-03-15T18:51:55.333Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/99f1b5dad345accb322c80c7821071554f791a95ee50c1c90041c157ae99/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:419a9d91bd238052642a51938af8ac05da5b3343becde08d5cdeab9046df9ee1", size = 203035, upload-time = "2026-03-15T18:51:56.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/9a/62c2cb6a531483b55dddff1a68b3d891a8b498f3ca555fbcf2978e804d9d/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5273b9f0b5835ff0350c0828faea623c68bfa65b792720c453e22b25cc72930f", size = 216321, upload-time = "2026-03-15T18:51:58.17Z" }, + { url = "https://files.pythonhosted.org/packages/6e/79/94a010ff81e3aec7c293eb82c28f930918e517bc144c9906a060844462eb/charset_normalizer-3.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0e901eb1049fdb80f5bd11ed5ea1e498ec423102f7a9b9e4645d5b8204ff2815", size = 208973, upload-time = "2026-03-15T18:51:59.998Z" }, + { url = "https://files.pythonhosted.org/packages/2a/57/4ecff6d4ec8585342f0c71bc03efaa99cb7468f7c91a57b105bcd561cea8/charset_normalizer-3.4.6-cp314-cp314-win32.whl", hash = "sha256:b4ff1d35e8c5bd078be89349b6f3a845128e685e751b6ea1169cf2160b344c4d", size = 144610, upload-time = "2026-03-15T18:52:02.213Z" }, + { url = "https://files.pythonhosted.org/packages/80/94/8434a02d9d7f168c25767c64671fead8d599744a05d6a6c877144c754246/charset_normalizer-3.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:74119174722c4349af9708993118581686f343adc1c8c9c007d59be90d077f3f", size = 154962, upload-time = "2026-03-15T18:52:03.658Z" }, + { url = "https://files.pythonhosted.org/packages/46/4c/48f2cdbfd923026503dfd67ccea45c94fd8fe988d9056b468579c66ed62b/charset_normalizer-3.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:e5bcc1a1ae744e0bb59641171ae53743760130600da8db48cbb6e4918e186e4e", size = 143595, upload-time = "2026-03-15T18:52:05.123Z" }, + { url = "https://files.pythonhosted.org/packages/31/93/8878be7569f87b14f1d52032946131bcb6ebbd8af3e20446bc04053dc3f1/charset_normalizer-3.4.6-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ad8faf8df23f0378c6d527d8b0b15ea4a2e23c89376877c598c4870d1b2c7866", size = 314828, upload-time = "2026-03-15T18:52:06.831Z" }, + { url = "https://files.pythonhosted.org/packages/06/b6/fae511ca98aac69ecc35cde828b0a3d146325dd03d99655ad38fc2cc3293/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f5ea69428fa1b49573eef0cc44a1d43bebd45ad0c611eb7d7eac760c7ae771bc", size = 208138, upload-time = "2026-03-15T18:52:08.239Z" }, + { url = "https://files.pythonhosted.org/packages/54/57/64caf6e1bf07274a1e0b7c160a55ee9e8c9ec32c46846ce59b9c333f7008/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:06a7e86163334edfc5d20fe104db92fcd666e5a5df0977cb5680a506fe26cc8e", size = 224679, upload-time = "2026-03-15T18:52:10.043Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cb/9ff5a25b9273ef160861b41f6937f86fae18b0792fe0a8e75e06acb08f1d/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e1f6e2f00a6b8edb562826e4632e26d063ac10307e80f7461f7de3ad8ef3f077", size = 223475, upload-time = "2026-03-15T18:52:11.854Z" }, + { url = "https://files.pythonhosted.org/packages/fc/97/440635fc093b8d7347502a377031f9605a1039c958f3cd18dcacffb37743/charset_normalizer-3.4.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b52c68d64c1878818687a473a10547b3292e82b6f6fe483808fb1468e2f52f", size = 215230, upload-time = "2026-03-15T18:52:13.325Z" }, + { url = "https://files.pythonhosted.org/packages/cd/24/afff630feb571a13f07c8539fbb502d2ab494019492aaffc78ef41f1d1d0/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:7504e9b7dc05f99a9bbb4525c67a2c155073b44d720470a148b34166a69c054e", size = 199045, upload-time = "2026-03-15T18:52:14.752Z" }, + { url = "https://files.pythonhosted.org/packages/e5/17/d1399ecdaf7e0498c327433e7eefdd862b41236a7e484355b8e0e5ebd64b/charset_normalizer-3.4.6-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:172985e4ff804a7ad08eebec0a1640ece87ba5041d565fff23c8f99c1f389484", size = 211658, upload-time = "2026-03-15T18:52:16.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/38/16baa0affb957b3d880e5ac2144caf3f9d7de7bc4a91842e447fbb5e8b67/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4be9f4830ba8741527693848403e2c457c16e499100963ec711b1c6f2049b7c7", size = 210769, upload-time = "2026-03-15T18:52:17.782Z" }, + { url = "https://files.pythonhosted.org/packages/05/34/c531bc6ac4c21da9ddfddb3107be2287188b3ea4b53b70fc58f2a77ac8d8/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:79090741d842f564b1b2827c0b82d846405b744d31e84f18d7a7b41c20e473ff", size = 201328, upload-time = "2026-03-15T18:52:19.553Z" }, + { url = "https://files.pythonhosted.org/packages/fa/73/a5a1e9ca5f234519c1953608a03fe109c306b97fdfb25f09182babad51a7/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:87725cfb1a4f1f8c2fc9890ae2f42094120f4b44db9360be5d99a4c6b0e03a9e", size = 225302, upload-time = "2026-03-15T18:52:21.043Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f6/cd782923d112d296294dea4bcc7af5a7ae0f86ab79f8fefbda5526b6cfc0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fcce033e4021347d80ed9c66dcf1e7b1546319834b74445f561d2e2221de5659", size = 211127, upload-time = "2026-03-15T18:52:22.491Z" }, + { url = "https://files.pythonhosted.org/packages/0e/c5/0b6898950627af7d6103a449b22320372c24c6feda91aa24e201a478d161/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ca0276464d148c72defa8bb4390cce01b4a0e425f3b50d1435aa6d7a18107602", size = 222840, upload-time = "2026-03-15T18:52:24.113Z" }, + { url = "https://files.pythonhosted.org/packages/7d/25/c4bba773bef442cbdc06111d40daa3de5050a676fa26e85090fc54dd12f0/charset_normalizer-3.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:197c1a244a274bb016dd8b79204850144ef77fe81c5b797dc389327adb552407", size = 216890, upload-time = "2026-03-15T18:52:25.541Z" }, + { url = "https://files.pythonhosted.org/packages/35/1a/05dacadb0978da72ee287b0143097db12f2e7e8d3ffc4647da07a383b0b7/charset_normalizer-3.4.6-cp314-cp314t-win32.whl", hash = "sha256:2a24157fa36980478dd1770b585c0f30d19e18f4fb0c47c13aa568f871718579", size = 155379, upload-time = "2026-03-15T18:52:27.05Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/d269d834cb3a76291651256f3b9a5945e81d0a49ab9f4a498964e83c0416/charset_normalizer-3.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:cd5e2801c89992ed8c0a3f0293ae83c159a60d9a5d685005383ef4caca77f2c4", size = 169043, upload-time = "2026-03-15T18:52:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/23/06/28b29fba521a37a8932c6a84192175c34d49f84a6d4773fa63d05f9aff22/charset_normalizer-3.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:47955475ac79cc504ef2704b192364e51d0d473ad452caedd0002605f780101c", size = 148523, upload-time = "2026-03-15T18:52:29.956Z" }, + { url = "https://files.pythonhosted.org/packages/2a/68/687187c7e26cb24ccbd88e5069f5ef00eba804d36dde11d99aad0838ab45/charset_normalizer-3.4.6-py3-none-any.whl", hash = "sha256:947cf925bc916d90adba35a64c82aace04fa39b46b52d4630ece166655905a69", size = 61455, upload-time = "2026-03-15T18:53:23.833Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "commitizen" +version = "4.13.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "charset-normalizer" }, + { name = "colorama" }, + { name = "decli" }, + { name = "deprecated" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "prompt-toolkit" }, + { name = "pyyaml" }, + { name = "questionary" }, + { name = "termcolor" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/44/10f95e8178ab5a584298726a4a94ceb83a7f77e00741fec4680df05fedd5/commitizen-4.13.9.tar.gz", hash = "sha256:2b4567ed50555e10920e5bd804a6a4e2c42ec70bb74f14a83f2680fe9eaf9727", size = 64145, upload-time = "2026-02-25T02:40:05.326Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/22/9b14ee0f17f0aad219a2fb37a293a57b8324d9d195c6ef6807bcd0bf2055/commitizen-4.13.9-py3-none-any.whl", hash = "sha256:d2af3d6a83cacec9d5200e17768942c5de6266f93d932c955986c60c4285f2db", size = 85373, upload-time = "2026-02-25T02:40:03.83Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, + { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, + { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, + { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, + { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, + { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, + { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, + { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, + { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, + { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[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" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "decli" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0c/59/d4ffff1dee2c8f6f2dd8f87010962e60f7b7847504d765c91ede5a466730/decli-0.6.3.tar.gz", hash = "sha256:87f9d39361adf7f16b9ca6e3b614badf7519da13092f2db3c80ca223c53c7656", size = 7564, upload-time = "2025-06-01T15:23:41.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/fa/ec878c28bc7f65b77e7e17af3522c9948a9711b9fa7fc4c5e3140a7e3578/decli-0.6.3-py3-none-any.whl", hash = "sha256:5152347c7bb8e3114ad65db719e5709b28d7f7f45bdb709f70167925e55640f3", size = 7989, upload-time = "2025-06-01T15:23:40.228Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[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 = "google-auth" +version = "2.49.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "pyasn1-modules" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" }, +] + +[package.optional-dependencies] +requests = [ + { name = "requests" }, +] + +[[package]] +name = "google-genai" +version = "1.68.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "google-auth", extra = ["requests"] }, + { name = "httpx" }, + { name = "pydantic" }, + { name = "requests" }, + { name = "sniffio" }, + { name = "tenacity" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/2c/f059982dbcb658cc535c81bbcbe7e2c040d675f4b563b03cdb01018a4bc3/google_genai-1.68.0.tar.gz", hash = "sha256:ac30c0b8bc630f9372993a97e4a11dae0e36f2e10d7c55eacdca95a9fa14ca96", size = 511285, upload-time = "2026-03-18T01:03:18.243Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/de/7d3ee9c94b74c3578ea4f88d45e8de9405902f857932334d81e89bce3dfa/google_genai-1.68.0-py3-none-any.whl", hash = "sha256:a1bc9919c0e2ea2907d1e319b65471d3d6d58c54822039a249fe1323e4178d15", size = 750912, upload-time = "2026-03-18T01:03:15.983Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "identify" +version = "2.6.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "jmespath" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, +] + +[[package]] +name = "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 = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mcp" +version = "1.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "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 = "ollama" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" }, +] + +[[package]] +name = "openai" +version = "2.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/15/203d537e58986b5673e7f232453a2a2f110f22757b15921cbdeea392e520/openai-2.29.0.tar.gz", hash = "sha256:32d09eb2f661b38d3edd7d7e1a2943d1633f572596febe64c0cd370c86d52bec", size = 671128, upload-time = "2026-03-17T17:53:49.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/b1/35b6f9c8cf9318e3dbb7146cc82dab4cf61182a8d5406fc9b50864362895/openai-2.29.0-py3-none-any.whl", hash = "sha256:b7c5de513c3286d17c5e29b92c4c98ceaf0d775244ac8159aeb1bddf840eb42a", size = 1141533, upload-time = "2026-03-17T17:53:47.348Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2c/1d/4049a9e8698361cc1a1aa03a6c59e4fa4c71e0c0f94a30f988a6876a2ae6/opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f", size = 70851, upload-time = "2026-03-04T14:17:21.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/bf/93795954016c522008da367da292adceed71cca6ee1717e1d64c83089099/opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9", size = 68676, upload-time = "2026-03-04T14:17:01.24Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "packaging" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/37/6bf8e66bfcee5d3c6515b79cb2ee9ad05fe573c20f7ceb288d0e7eeec28c/opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7", size = 32606, upload-time = "2026-03-04T14:20:16.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/3e/f6f10f178b6316de67f0dfdbbb699a24fbe8917cf1743c1595fb9dcdd461/opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63", size = 33448, upload-time = "2026-03-04T14:19:02.447Z" }, +] + +[[package]] +name = "opentelemetry-instrumentation-threading" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/8f/8dedba66100cda58af057926449a5e58e6c008bec02bc2746c03c3d85dcd/opentelemetry_instrumentation_threading-0.61b0.tar.gz", hash = "sha256:38e0263c692d15a7a458b3fa0286d29290448fa4ac4c63045edac438c6113433", size = 9163, upload-time = "2026-03-04T14:20:50.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/77/c06d960aede1a014812aa4fafde0ae546d790f46416fbeafa2b32095aae3/opentelemetry_instrumentation_threading-0.61b0-py3-none-any.whl", hash = "sha256:735f4a1dc964202fc8aff475efc12bb64e6566f22dff52d5cb5de864b3fe1a70", size = 9337, upload-time = "2026-03-04T14:19:57.983Z" }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/fd/3c3125b20ba18ce2155ba9ea74acb0ae5d25f8cd39cfd37455601b7955cc/opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2", size = 184252, upload-time = "2026-03-04T14:17:31.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/c5/6a852903d8bfac758c6dc6e9a68b015d3c33f2f1be5e9591e0f4b69c7e0a/opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1", size = 141951, upload-time = "2026-03-04T14:17:17.961Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.61b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/c0/4ae7973f3c2cfd2b6e321f1675626f0dab0a97027cc7a297474c9c8f3d04/opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a", size = 145755, upload-time = "2026-03-04T14:17:32.664Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/37/cc6a55e448deaa9b27377d087da8615a3416d8ad523d5960b78dbeadd02a/opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2", size = 231621, upload-time = "2026-03-04T14:17:19.33Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +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 = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940, upload-time = "2025-04-15T09:18:47.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810, upload-time = "2025-04-15T09:18:44.753Z" }, +] + +[[package]] +name = "pyasn1" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" }, +] + +[[package]] +name = "pyasn1-modules" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" }, +] + +[[package]] +name = "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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.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" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +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.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055, upload-time = "2026-03-19T01:43:08.248Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524, upload-time = "2026-03-19T01:43:07.045Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +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/7c/af/449a6a91e5d6db51420875c54f6aff7c97a86a3b13a0b4f1a5c13b988de3/pywin32-311-cp311-cp311-win32.whl", hash = "sha256:184eb5e436dea364dcd3d2316d577d625c0351bf237c4e9a5fabbcfa5a58b151", size = 8697031, upload-time = "2025-07-14T20:13:13.266Z" }, + { url = "https://files.pythonhosted.org/packages/51/8f/9bb81dd5bb77d22243d33c8397f09377056d5c687aa6d4042bea7fbf8364/pywin32-311-cp311-cp311-win_amd64.whl", hash = "sha256:3ce80b34b22b17ccbd937a6e78e7225d80c52f5ab9940fe0506a1a16f3dab503", size = 9508308, upload-time = "2025-07-14T20:13:15.147Z" }, + { url = "https://files.pythonhosted.org/packages/44/7b/9c2ab54f74a138c491aba1b1cd0795ba61f144c711daea84a88b63dc0f6c/pywin32-311-cp311-cp311-win_arm64.whl", hash = "sha256:a733f1388e1a842abb67ffa8e7aad0e70ac519e09b0f6a784e65a136ec7cefd2", size = 8703930, upload-time = "2025-07-14T20:13:16.945Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, + { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, + { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, + { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, + { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "questionary" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "prompt-toolkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/45/eafb0bba0f9988f6a2520f9ca2df2c82ddfa8d67c95d6625452e97b204a5/questionary-2.1.1.tar.gz", hash = "sha256:3d7e980292bb0107abaa79c68dd3eee3c561b83a0f89ae482860b181c8bd412d", size = 25845, upload-time = "2025-08-28T19:00:20.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[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/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, + { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, + { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, + { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, + { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, + { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, + { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, + { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, + { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +] + +[[package]] +name = "rust-just" +version = "1.47.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/b7/dbadf54b6e250cb561cbd2c43d45535e7778f03ac9986fa1a13888788c46/rust_just-1.47.1.tar.gz", hash = "sha256:f2b31e30ae4c344c72b1ee37910709dd091be047a26c3c7e2c6fb51de9611c2b", size = 1516910, upload-time = "2026-03-17T02:39:45.042Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/12/d657032838c49bff7d8163ce83fd99456dad8a1b8f31c93b8b707f7d0d7d/rust_just-1.47.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:88b2da8ec4412f4ea59f1536e163e1ce5b2a47cb24751169e559f3caf0d730cd", size = 1745362, upload-time = "2026-03-17T02:39:07.331Z" }, + { url = "https://files.pythonhosted.org/packages/c3/b3/3de5ee2bb1dd066fcc662d5976bf31abc5d26dbf746573849f9c27961ee8/rust_just-1.47.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:98d2690849379cc1c02b271054b5087d750ceee80fc962177ce11f3b384ec25d", size = 1619527, upload-time = "2026-03-17T02:39:09.98Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f3/b70f57f06de0381a28b5755c36de19c1dcf2a4a559febb4fb37a117a87fb/rust_just-1.47.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3843d9904f5adb9d6534e720aca4bd0b2fac8e6721878cb42e25dd97299eebe7", size = 1696352, upload-time = "2026-03-17T02:39:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/ea/16/a141629bec011384675aedbb5a70b20ea28da6bba34cc9e5831eed4ba850/rust_just-1.47.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:15b8ff1f4427a5de02d93e058211d560ddd35e4d6eb05b6e6c954e3f8b51665c", size = 1663024, upload-time = "2026-03-17T02:39:15.749Z" }, + { url = "https://files.pythonhosted.org/packages/8f/5e/0be546347b81c3b9f17f0540e27422965a5908981561263b5c6c6ba1de81/rust_just-1.47.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2db4aa25e8dc9e0ac320bda46f10c27cbab43e877d2a05b67cdfaf76347eb6e3", size = 1852014, upload-time = "2026-03-17T02:39:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/20/c4/407f166c0f9cbd135f6a5aa66858f9f5d7e98e2e06344de1498a3f16aebd/rust_just-1.47.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8337729955261dc0e1ded13f12f8ef9bbe83feb7916ee740f49494ed7a52912d", size = 1911912, upload-time = "2026-03-17T02:39:20.773Z" }, + { url = "https://files.pythonhosted.org/packages/73/31/f97a02c85ef0ca2e4a6b50d6605b3436b37d6926561ba5db02075b1a16d5/rust_just-1.47.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:160a3b0e9f9e271334bb0d68ff8fa5b71e339d9f7504c935a2890fa70a230a49", size = 1901022, upload-time = "2026-03-17T02:39:23.601Z" }, + { url = "https://files.pythonhosted.org/packages/ac/80/3c2c6faa47a3f7e6cffe6e63e2937c6eb48b5a0d89b6dad8fc2ca34558db/rust_just-1.47.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c186f3c30cf579ecec020017947c759d171f133c661de599d5e33244156a7c6", size = 1840084, upload-time = "2026-03-17T02:39:26.376Z" }, + { url = "https://files.pythonhosted.org/packages/6b/69/dfbd160f475217bc2a926dd56053bb3ab4aff7d035476c30186664bf5fd3/rust_just-1.47.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0359577ecf8bc70e25c976e10274ce8eabb1f6681d29899fc97bd6108890a944", size = 1724032, upload-time = "2026-03-17T02:39:29.098Z" }, + { url = "https://files.pythonhosted.org/packages/1e/47/afb7b0d9d0c2271a5298baba5e45e52c7184caa523d90cb8896954de0577/rust_just-1.47.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:182f0db4dd3ad512459439e265da491c15425807952a99d5ba378007f8fd1e3f", size = 1689445, upload-time = "2026-03-17T02:39:31.829Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/89df1a3e7e7f88d4e34abd9892143273d47b8b4bc1f42099d1576fa45076/rust_just-1.47.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:4bbe375d21eaea4c3c912274819053f62987ddd916e33631194b556a36826566", size = 1840634, upload-time = "2026-03-17T02:39:34.359Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7d/721e517b92129d02cfcd22282e7b0ec04e9e8fc238ad02e2b3c19380d071/rust_just-1.47.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e09f482daac84bfbdfdef70df67a47684b9f145e33e481820c32a493defc76ef", size = 1906775, upload-time = "2026-03-17T02:39:37.012Z" }, + { url = "https://files.pythonhosted.org/packages/f7/98/fa18ba2ee26b53553ec388cde1db4b17a9bca2d566f7e478ff289639addf/rust_just-1.47.1-py3-none-win32.whl", hash = "sha256:f58693c9f12b3f512a5890ad0dcf5d5549dda7d9d6982472ab183c2ef5e3ea5a", size = 1639490, upload-time = "2026-03-17T02:39:39.716Z" }, + { url = "https://files.pythonhosted.org/packages/72/cb/1bc3ead533eca647d7099126f60b26d2e950752b0f06648d48c19889d367/rust_just-1.47.1-py3-none-win_amd64.whl", hash = "sha256:28186a40b2681802826dafb89e43af11c72ff6df80ff3f531cf82ffdf9501e2b", size = 1810402, upload-time = "2026-03-17T02:39:42.3Z" }, +] + +[[package]] +name = "s3transfer" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/04/74127fc843314818edfa81b5540e26dd537353b123a4edc563109d8f17dd/s3transfer-0.16.0.tar.gz", hash = "sha256:8e990f13268025792229cd52fa10cb7163744bf56e719e0b9cb925ab79abf920", size = 153827, upload-time = "2025-12-01T02:30:59.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "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 = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" }, +] + +[[package]] +name = "stevedore" +version = "5.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6d/90764092216fa560f6587f83bb70113a8ba510ba436c6476a2b47359057c/stevedore-5.7.0.tar.gz", hash = "sha256:31dd6fe6b3cbe921e21dcefabc9a5f1cf848cf538a1f27543721b8ca09948aa3", size = 516200, upload-time = "2026-02-20T13:27:06.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/06/36d260a695f383345ab5bbc3fd447249594ae2fa8dfd19c533d5ae23f46b/stevedore-5.7.0-py3-none-any.whl", hash = "sha256:fd25efbb32f1abb4c9e502f385f0018632baac11f9ee5d1b70f88cc5e22ad4ed", size = 54483, upload-time = "2026-02-20T13:27:05.561Z" }, +] + +[[package]] +name = "strands-agents" +version = "1.32.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boto3" }, + { name = "botocore" }, + { name = "docstring-parser" }, + { name = "jsonschema" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "opentelemetry-instrumentation-threading" }, + { name = "opentelemetry-sdk" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "typing-extensions" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d4/b9/c18d13bbf85c23eb66d9f61bced0c1f5a3ac18916331eabde049f6c4dd33/strands_agents-1.32.0.tar.gz", hash = "sha256:2e399bc5ea98d91dbcdf79913115aa579a6bb3251dfe6c15be114821cad893a4", size = 776171, upload-time = "2026-03-20T14:07:41.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/a9/7e00371130dec1ae16df3fd0f7450aa2b92859b625818c793d178b2f1835/strands_agents-1.32.0-py3-none-any.whl", hash = "sha256:60c0eaae32ee1fc366ecebb10bca07681015104c111472a7378f71bbcdedbba4", size = 387030, upload-time = "2026-03-20T14:07:39.754Z" }, +] + +[package.optional-dependencies] +gemini = [ + { name = "google-genai" }, +] +ollama = [ + { name = "ollama" }, +] +openai = [ + { name = "openai" }, +] + +[[package]] +name = "strands-compose" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "mcp" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "strands-agents" }, +] + +[package.optional-dependencies] +agentcore-memory = [ + { name = "bedrock-agentcore" }, +] +gemini = [ + { name = "strands-agents", extra = ["gemini"] }, +] +ollama = [ + { name = "strands-agents", extra = ["ollama"] }, +] +openai = [ + { name = "strands-agents", extra = ["openai"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "commitizen" }, + { name = "coverage" }, + { name = "pre-commit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, + { name = "pytest-xdist" }, + { name = "ruff" }, + { name = "rust-just" }, + { name = "starlette" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "bedrock-agentcore", marker = "extra == 'agentcore-memory'", specifier = ">=1.4.0" }, + { name = "mcp", specifier = ">=1.24.0" }, + { name = "pydantic", specifier = ">=2.12.5" }, + { name = "pyyaml", specifier = ">=6.0.0" }, + { name = "strands-agents", specifier = "~=1.32.0" }, + { name = "strands-agents", extras = ["gemini"], marker = "extra == 'gemini'", specifier = "~=1.32.0" }, + { name = "strands-agents", extras = ["ollama"], marker = "extra == 'ollama'", specifier = "~=1.32.0" }, + { name = "strands-agents", extras = ["openai"], marker = "extra == 'openai'", specifier = "~=1.32.0" }, +] +provides-extras = ["agentcore-memory", "ollama", "openai", "gemini"] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.9.2" }, + { name = "commitizen", specifier = ">=4.8.4" }, + { name = "coverage", specifier = ">=7.12.0" }, + { name = "pre-commit", specifier = ">=4.3.0" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", specifier = ">=1.2.0" }, + { name = "pytest-cov", specifier = ">=7.0.0" }, + { name = "pytest-mock", specifier = ">=3.15.1" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, + { name = "ruff", specifier = ">=0.14.8" }, + { name = "rust-just", specifier = ">=1.42.4" }, + { name = "starlette", specifier = ">=0.27.0" }, + { name = "ty", specifier = ">=0.0.17" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "termcolor" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/79/cf31d7a93a8fdc6aa0fbb665be84426a8c5a557d9240b6239e9e11e35fc5/termcolor-3.3.0.tar.gz", hash = "sha256:348871ca648ec6a9a983a13ab626c0acce02f515b9e1983332b17af7979521c5", size = 14434, upload-time = "2025-12-29T12:55:21.882Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/d1/8bb87d21e9aeb323cc03034f5eaf2c8f69841e40e4853c2627edf8111ed3/termcolor-3.3.0-py3-none-any.whl", hash = "sha256:cf642efadaf0a8ebbbf4bc7a31cec2f9b5f21a9f726f4ccbb08192c9c26f43a5", size = 7734, upload-time = "2025-12-29T12:55:20.718Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "ty" +version = "0.0.24" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7a/96/652a425030f95dc2c9548d9019e52502e17079e1daeefbc4036f1c0905b4/ty-0.0.24.tar.gz", hash = "sha256:9fe42f6b98207bdaef51f71487d6d087f2cb02555ee3939884d779b2b3cc8bfc", size = 5354286, upload-time = "2026-03-19T16:55:57.035Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/e5/34457ee11708e734ba81ad65723af83030e484f961e281d57d1eecf08951/ty-0.0.24-py3-none-linux_armv6l.whl", hash = "sha256:1ab4f1f61334d533a3fdf5d9772b51b1300ac5da4f3cdb0be9657a3ccb2ce3e7", size = 10394877, upload-time = "2026-03-19T16:55:54.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/81/bc9a1b1a87f43db15ab64ad781a4f999734ec3b470ad042624fa875b20e6/ty-0.0.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:facbf2c4aaa6985229e08f8f9bf152215eb078212f22b5c2411f35386688ab42", size = 10211109, upload-time = "2026-03-19T16:55:28.554Z" }, + { url = "https://files.pythonhosted.org/packages/e4/63/cfc805adeaa61d63ba3ea71127efa7d97c40ba36d97ee7bd957341d05107/ty-0.0.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b6d2a3b6d4470c483552a31e9b368c86f154dcc964bccb5406159dc9cd362246", size = 9694769, upload-time = "2026-03-19T16:55:34.309Z" }, + { url = "https://files.pythonhosted.org/packages/33/09/edc220726b6ec44a58900401f6b27140997ef15026b791e26b69a6e69eb5/ty-0.0.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0c94c25d0500939fd5f8f16ce41cbed5b20528702c1d649bf80300253813f0a2", size = 10176287, upload-time = "2026-03-19T16:55:37.17Z" }, + { url = "https://files.pythonhosted.org/packages/f8/bf/cbe2227be711e65017655d8ee4d050f4c92b113fb4dc4c3bd6a19d3a86d8/ty-0.0.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:89cbe7bc7df0fab02dbd8cda79b737df83f1ef7fb573b08c0ee043dc68cffb08", size = 10214832, upload-time = "2026-03-19T16:56:08.518Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/d15803ee47e9143d10e10bd81ccc14761d08758082bda402950685f0ddfe/ty-0.0.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:db2c5d269bcc9b764850c99f457b5018a79b3ef40ecfbc03344e65effd6cf743", size = 10709892, upload-time = "2026-03-19T16:56:05.727Z" }, + { url = "https://files.pythonhosted.org/packages/36/12/6db0d86c477147f67b9052de209421d76c3e855197b000c25fcbbe86b3a2/ty-0.0.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ba44512db5b97c3bbd59d93e11296e8548d0c9a3bdd1280de36d7ff22d351896", size = 11280872, upload-time = "2026-03-19T16:56:02.899Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fc/155fe83a97c06d33ccc9e0f428258b32df2e08a428300c715d34757f0111/ty-0.0.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a52b7f589c3205512a9c50ba5b2b1e8c0698b72e51b8b9285c90420c06f1cae8", size = 11060520, upload-time = "2026-03-19T16:55:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/ac/f1/32c05a1c4c3c2a95c5b7361dee03a9bf1231d4ad096b161c838b45bce5a0/ty-0.0.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7981df5c709c054da4ac5d7c93f8feb8f45e69e829e4461df4d5f0988fe67d04", size = 10791455, upload-time = "2026-03-19T16:55:25.728Z" }, + { url = "https://files.pythonhosted.org/packages/17/2c/53c1ea6bedfa4d4ab64d4de262d8f5e405ecbffefd364459c628c0310d33/ty-0.0.24-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b2860151ad95a00d0f0280b8fef79900d08dcd63276b57e6e5774f2c055979c5", size = 10156708, upload-time = "2026-03-19T16:55:45.563Z" }, + { url = "https://files.pythonhosted.org/packages/45/39/7d2919cf194707169474d80720a5f3d793e983416f25e7ffcf80504c9df2/ty-0.0.24-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:5674a1146d927ab77ff198a88e0c4505134ced342a0e7d1beb4a076a728b7496", size = 10236263, upload-time = "2026-03-19T16:55:31.474Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7f/48eac722f2fd12a5b7aae0effdcb75c46053f94b783d989e3ef0d7380082/ty-0.0.24-py3-none-musllinux_1_2_i686.whl", hash = "sha256:438ecbf1608a9b16dd84502f3f1b23ef2ef32bbd0ab3e0ca5a82f0e0d1cd41ea", size = 10402559, upload-time = "2026-03-19T16:55:39.602Z" }, + { url = "https://files.pythonhosted.org/packages/75/e0/8cf868b9749ce1e5166462759545964e95b02353243594062b927d8bff2a/ty-0.0.24-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ddeed3098dd92a83964e7aa7b41e509ba3530eb539fc4cd8322ff64a09daf1f5", size = 10893684, upload-time = "2026-03-19T16:55:51.439Z" }, + { url = "https://files.pythonhosted.org/packages/17/9f/f54bf3be01d2c2ed731d10a5afa3324dc66f987a6ae0a4a6cbfa2323d080/ty-0.0.24-py3-none-win32.whl", hash = "sha256:83013fb3a4764a8f8bcc6ca11ff8bdfd8c5f719fc249241cb2b8916e80778eb1", size = 9781542, upload-time = "2026-03-19T16:56:11.588Z" }, + { url = "https://files.pythonhosted.org/packages/fb/49/c004c5cc258b10b3a145666e9a9c28ae7678bc958c8926e8078d5d769081/ty-0.0.24-py3-none-win_amd64.whl", hash = "sha256:748a60eb6912d1cf27aaab105ffadb6f4d2e458a3fcadfbd3cf26db0d8062eeb", size = 10764801, upload-time = "2026-03-19T16:55:42.752Z" }, + { url = "https://files.pythonhosted.org/packages/e2/59/006a074e185bfccf5e4c026015245ab4fcd2362b13a8d24cf37a277909a9/ty-0.0.24-py3-none-win_arm64.whl", hash = "sha256:280a3d31e86d0721947238f17030c33f0911cae851d108ea9f4e3ab12a5ed01f", size = 10194093, upload-time = "2026-03-19T16:55:48.303Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.42.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" }, +] + +[[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 = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + +[[package]] +name = "wrapt" +version = "1.17.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/8f/aeb76c5b46e273670962298c23e7ddde79916cb74db802131d49a85e4b7d/wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0", size = 55547, upload-time = "2025-08-12T05:53:21.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/db/00e2a219213856074a213503fdac0511203dceefff26e1daa15250cc01a0/wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7", size = 53482, upload-time = "2025-08-12T05:51:45.79Z" }, + { url = "https://files.pythonhosted.org/packages/5e/30/ca3c4a5eba478408572096fe9ce36e6e915994dd26a4e9e98b4f729c06d9/wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85", size = 38674, upload-time = "2025-08-12T05:51:34.629Z" }, + { url = "https://files.pythonhosted.org/packages/31/25/3e8cc2c46b5329c5957cec959cb76a10718e1a513309c31399a4dad07eb3/wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f", size = 38959, upload-time = "2025-08-12T05:51:56.074Z" }, + { url = "https://files.pythonhosted.org/packages/5d/8f/a32a99fc03e4b37e31b57cb9cefc65050ea08147a8ce12f288616b05ef54/wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311", size = 82376, upload-time = "2025-08-12T05:52:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/31/57/4930cb8d9d70d59c27ee1332a318c20291749b4fba31f113c2f8ac49a72e/wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1", size = 83604, upload-time = "2025-08-12T05:52:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f3/1afd48de81d63dd66e01b263a6fbb86e1b5053b419b9b33d13e1f6d0f7d0/wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5", size = 82782, upload-time = "2025-08-12T05:52:12.626Z" }, + { url = "https://files.pythonhosted.org/packages/1e/d7/4ad5327612173b144998232f98a85bb24b60c352afb73bc48e3e0d2bdc4e/wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2", size = 82076, upload-time = "2025-08-12T05:52:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/59/e0adfc831674a65694f18ea6dc821f9fcb9ec82c2ce7e3d73a88ba2e8718/wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89", size = 36457, upload-time = "2025-08-12T05:53:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/83/88/16b7231ba49861b6f75fc309b11012ede4d6b0a9c90969d9e0db8d991aeb/wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77", size = 38745, upload-time = "2025-08-12T05:53:02.885Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/c4d4f3398ec073012c51d1c8d87f715f56765444e1a4b11e5180577b7e6e/wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a", size = 36806, upload-time = "2025-08-12T05:52:53.368Z" }, + { url = "https://files.pythonhosted.org/packages/9f/41/cad1aba93e752f1f9268c77270da3c469883d56e2798e7df6240dcb2287b/wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0", size = 53998, upload-time = "2025-08-12T05:51:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/60/f8/096a7cc13097a1869fe44efe68dace40d2a16ecb853141394047f0780b96/wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba", size = 39020, upload-time = "2025-08-12T05:51:35.906Z" }, + { url = "https://files.pythonhosted.org/packages/33/df/bdf864b8997aab4febb96a9ae5c124f700a5abd9b5e13d2a3214ec4be705/wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd", size = 39098, upload-time = "2025-08-12T05:51:57.474Z" }, + { url = "https://files.pythonhosted.org/packages/9f/81/5d931d78d0eb732b95dc3ddaeeb71c8bb572fb01356e9133916cd729ecdd/wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828", size = 88036, upload-time = "2025-08-12T05:52:34.784Z" }, + { url = "https://files.pythonhosted.org/packages/ca/38/2e1785df03b3d72d34fc6252d91d9d12dc27a5c89caef3335a1bbb8908ca/wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9", size = 88156, upload-time = "2025-08-12T05:52:13.599Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8b/48cdb60fe0603e34e05cffda0b2a4adab81fd43718e11111a4b0100fd7c1/wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396", size = 87102, upload-time = "2025-08-12T05:52:14.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/51/d81abca783b58f40a154f1b2c56db1d2d9e0d04fa2d4224e357529f57a57/wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc", size = 87732, upload-time = "2025-08-12T05:52:36.165Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b1/43b286ca1392a006d5336412d41663eeef1ad57485f3e52c767376ba7e5a/wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe", size = 36705, upload-time = "2025-08-12T05:53:07.123Z" }, + { url = "https://files.pythonhosted.org/packages/28/de/49493f962bd3c586ab4b88066e967aa2e0703d6ef2c43aa28cb83bf7b507/wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c", size = 38877, upload-time = "2025-08-12T05:53:05.436Z" }, + { url = "https://files.pythonhosted.org/packages/f1/48/0f7102fe9cb1e8a5a77f80d4f0956d62d97034bbe88d33e94699f99d181d/wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6", size = 36885, upload-time = "2025-08-12T05:52:54.367Z" }, + { url = "https://files.pythonhosted.org/packages/fc/f6/759ece88472157acb55fc195e5b116e06730f1b651b5b314c66291729193/wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0", size = 54003, upload-time = "2025-08-12T05:51:48.627Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a9/49940b9dc6d47027dc850c116d79b4155f15c08547d04db0f07121499347/wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77", size = 39025, upload-time = "2025-08-12T05:51:37.156Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/6a08de0f2c96dcdd7fe464d7420ddb9a7655a6561150e5fc4da9356aeaab/wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7", size = 39108, upload-time = "2025-08-12T05:51:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/0c/37/6faf15cfa41bf1f3dba80cd3f5ccc6622dfccb660ab26ed79f0178c7497f/wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277", size = 88072, upload-time = "2025-08-12T05:52:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/78/f2/efe19ada4a38e4e15b6dff39c3e3f3f73f5decf901f66e6f72fe79623a06/wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d", size = 88214, upload-time = "2025-08-12T05:52:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/ca86701e9de1622b16e09689fc24b76f69b06bb0150990f6f4e8b0eeb576/wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa", size = 87105, upload-time = "2025-08-12T05:52:17.914Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e0/d10bd257c9a3e15cbf5523025252cc14d77468e8ed644aafb2d6f54cb95d/wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050", size = 87766, upload-time = "2025-08-12T05:52:39.243Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cf/7d848740203c7b4b27eb55dbfede11aca974a51c3d894f6cc4b865f42f58/wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8", size = 36711, upload-time = "2025-08-12T05:53:10.074Z" }, + { url = "https://files.pythonhosted.org/packages/57/54/35a84d0a4d23ea675994104e667ceff49227ce473ba6a59ba2c84f250b74/wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb", size = 38885, upload-time = "2025-08-12T05:53:08.695Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/66e54407c59d7b02a3c4e0af3783168fff8e5d61def52cda8728439d86bc/wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16", size = 36896, upload-time = "2025-08-12T05:52:55.34Z" }, + { url = "https://files.pythonhosted.org/packages/02/a2/cd864b2a14f20d14f4c496fab97802001560f9f41554eef6df201cd7f76c/wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39", size = 54132, upload-time = "2025-08-12T05:51:49.864Z" }, + { url = "https://files.pythonhosted.org/packages/d5/46/d011725b0c89e853dc44cceb738a307cde5d240d023d6d40a82d1b4e1182/wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235", size = 39091, upload-time = "2025-08-12T05:51:38.935Z" }, + { url = "https://files.pythonhosted.org/packages/2e/9e/3ad852d77c35aae7ddebdbc3b6d35ec8013af7d7dddad0ad911f3d891dae/wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c", size = 39172, upload-time = "2025-08-12T05:51:59.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f7/c983d2762bcce2326c317c26a6a1e7016f7eb039c27cdf5c4e30f4160f31/wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b", size = 87163, upload-time = "2025-08-12T05:52:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0f/f673f75d489c7f22d17fe0193e84b41540d962f75fce579cf6873167c29b/wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa", size = 87963, upload-time = "2025-08-12T05:52:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/df/61/515ad6caca68995da2fac7a6af97faab8f78ebe3bf4f761e1b77efbc47b5/wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7", size = 86945, upload-time = "2025-08-12T05:52:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bd/4e70162ce398462a467bc09e768bee112f1412e563620adc353de9055d33/wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4", size = 86857, upload-time = "2025-08-12T05:52:43.043Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/da8560695e9284810b8d3df8a19396a6e40e7518059584a1a394a2b35e0a/wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10", size = 37178, upload-time = "2025-08-12T05:53:12.605Z" }, + { url = "https://files.pythonhosted.org/packages/db/c8/b71eeb192c440d67a5a0449aaee2310a1a1e8eca41676046f99ed2487e9f/wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6", size = 39310, upload-time = "2025-08-12T05:53:11.106Z" }, + { url = "https://files.pythonhosted.org/packages/45/20/2cda20fd4865fa40f86f6c46ed37a2a8356a7a2fde0773269311f2af56c7/wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58", size = 37266, upload-time = "2025-08-12T05:52:56.531Z" }, + { url = "https://files.pythonhosted.org/packages/77/ed/dd5cf21aec36c80443c6f900449260b80e2a65cf963668eaef3b9accce36/wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a", size = 56544, upload-time = "2025-08-12T05:51:51.109Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/450c651cc753877ad100c7949ab4d2e2ecc4d97157e00fa8f45df682456a/wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067", size = 40283, upload-time = "2025-08-12T05:51:39.912Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/2fcad95994d9b572db57632acb6f900695a648c3e063f2cd344b3f5c5a37/wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454", size = 40366, upload-time = "2025-08-12T05:52:00.693Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/f4472f2fdde2d4617975144311f8800ef73677a159be7fe61fa50997d6c0/wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e", size = 108571, upload-time = "2025-08-12T05:52:44.521Z" }, + { url = "https://files.pythonhosted.org/packages/cc/01/9b85a99996b0a97c8a17484684f206cbb6ba73c1ce6890ac668bcf3838fb/wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f", size = 113094, upload-time = "2025-08-12T05:52:22.618Z" }, + { url = "https://files.pythonhosted.org/packages/25/02/78926c1efddcc7b3aa0bc3d6b33a822f7d898059f7cd9ace8c8318e559ef/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056", size = 110659, upload-time = "2025-08-12T05:52:24.057Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ee/c414501ad518ac3e6fe184753632fe5e5ecacdcf0effc23f31c1e4f7bfcf/wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804", size = 106946, upload-time = "2025-08-12T05:52:45.976Z" }, + { url = "https://files.pythonhosted.org/packages/be/44/a1bd64b723d13bb151d6cc91b986146a1952385e0392a78567e12149c7b4/wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977", size = 38717, upload-time = "2025-08-12T05:53:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/79/d9/7cfd5a312760ac4dd8bf0184a6ee9e43c33e47f3dadc303032ce012b8fa3/wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116", size = 41334, upload-time = "2025-08-12T05:53:14.178Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/10ad9781128ed2f99dbc474f43283b13fea8ba58723e98844367531c18e9/wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6", size = 38471, upload-time = "2025-08-12T05:52:57.784Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f6/a933bd70f98e9cf3e08167fc5cd7aaaca49147e48411c0bd5ae701bb2194/wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22", size = 23591, upload-time = "2025-08-12T05:53:20.674Z" }, +] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +]